From 16eb5a8ddaf80a23fde77373932be9a3956d507e Mon Sep 17 00:00:00 2001 From: warc0s Date: Mon, 17 Aug 2026 09:03:32 +0200 Subject: [PATCH] fix(core): preserve developer messages OMP converts custom and hook messages (advisor notes, todo reminders, retry nudges) to role "developer" before calling the provider. messagesToCC() only handled user, assistant, and toolResult, so those messages were dropped before params.messages was sent to /alpha/generate. Steering still interrupted pending tools, but the model never saw the message content. /alpha/generate has no developer role: the official command-code CLI (0.32.3) only emits user, assistant, and tool messages plus a separate params.system. Forward developer messages as user messages with identical content in the same chronological position. Hoisting them into params.system would turn a mid-conversation note into a global top-priority instruction. --- CHANGELOG.md | 2 + src/converters.ts | 7 ++- tests/test-pure-functions.ts | 83 ++++++++++++++++++++++++++++++++++++ tests/test-stream.ts | 50 ++++++++++++++++++++++ 4 files changed, 141 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cae0a0..01bf07a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Stop silently dropping `role: "developer"` messages (for example OMP advisor steering notes, reminders, and nudges). `/alpha/generate` only accepts `user`, `assistant`, and `tool` roles, so developer messages are now forwarded as `user` messages with identical content in the same chronological position instead of disappearing from the request. + ## 0.5.1 - 2026-08-11 - Add model-specific image input capabilities from the `command-code@1.15.1` catalog and forward user and tool-result images using the current Command Code wire format. diff --git a/src/converters.ts b/src/converters.ts index 4012fbb..6480263 100644 --- a/src/converters.ts +++ b/src/converters.ts @@ -190,7 +190,12 @@ export function messagesToCC( const pairedToolCallIds = completeToolCallIds(messages) for (const message of messages ?? []) { - if (message.role === "user") { + if (message.role === "user" || message.role === "developer") { + // Hosts such as OMP steer the agent by injecting developer-role messages + // (advisor notes, reminders, nudges) mid-conversation. /alpha/generate + // only accepts user, assistant, and tool roles, so degrade the role to + // user instead of dropping the message. Content and chronological + // position are preserved; system-prompt hoisting would change semantics. out.push({ role: "user", content: userContentToCommandCode(message.content, allowImages), diff --git a/tests/test-pure-functions.ts b/tests/test-pure-functions.ts index 136025f..c5d235a 100644 --- a/tests/test-pure-functions.ts +++ b/tests/test-pure-functions.ts @@ -626,6 +626,89 @@ describe("messagesToCC()", () => { it("handles empty conversations", () => { assert.deepEqual(messagesToCC([]), []) }) + + it("keeps developer messages instead of dropping them", () => { + const result = messagesToCC([ + { role: "user", content: "start" }, + { role: "developer", content: "mid-conversation steering note" }, + ]) + + assert.deepEqual(result, [ + { role: "user", content: "start" }, + { role: "user", content: "mid-conversation steering note" }, + ]) + }) + + it("converts developer text parts with the same shape as user content", () => { + const result = messagesToCC([ + { + role: "developer", + content: [{ type: "text", text: "reminder one" }], + }, + ]) + + assert.deepEqual(result, [ + { + role: "user", + content: [{ type: "text", text: "reminder one" }], + }, + ]) + }) + + it("preserves advisory XML verbatim in the serialized request messages", () => { + const advisory = + '\nStop and correct the benchmark.\n' + const serialized = JSON.stringify(messagesToCC([{ role: "developer", content: advisory }])) + + assert.deepEqual(JSON.parse(serialized), [{ role: "user", content: advisory }]) + }) + + it("keeps developer advisories in chronological position without hoisting", () => { + const advisory = + '\nStop and correct the benchmark.\n' + const result = messagesToCC([ + { role: "user", content: "run the benchmark" }, + { + role: "assistant", + content: [ + { type: "text", text: "running it" }, + { type: "toolCall", id: "c1", name: "bash", arguments: { command: "bench" } }, + ], + }, + { + role: "toolResult", + toolCallId: "c1", + toolName: "bash", + content: [{ type: "text", text: "benchmark output" }], + }, + { role: "developer", content: advisory }, + { role: "user", content: "continue" }, + ]) + + assert.deepEqual(result, [ + { role: "user", content: "run the benchmark" }, + { + role: "assistant", + content: [ + { type: "text", text: "running it" }, + { type: "tool-call", toolCallId: "c1", toolName: "bash", input: { command: "bench" } }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "c1", + toolName: "bash", + output: { type: "text", value: "benchmark output" }, + }, + ], + }, + { role: "user", content: advisory }, + { role: "user", content: "continue" }, + ]) + }) }) describe("parseStreamEventLine()", () => { diff --git a/tests/test-stream.ts b/tests/test-stream.ts index e30ac49..82ed325 100644 --- a/tests/test-stream.ts +++ b/tests/test-stream.ts @@ -495,6 +495,56 @@ describe("streamCommandCode — request serialization", () => { assert.equal(headers["x-session-id"], undefined) }) + it("sends developer advisories as user messages in position, without system hoisting", async () => { + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "finish", finishReason: "stop" })], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + const advisory = + '\nStop and correct the benchmark.\n' + const context = makeContext({ + messages: [ + { role: "user", content: "run the benchmark" }, + { + role: "assistant", + content: [ + { type: "text", text: "running it" }, + { type: "toolCall", id: "c1", name: "bash", arguments: { command: "bench" } }, + ], + }, + { + role: "toolResult", + toolCallId: "c1", + toolName: "bash", + content: [{ type: "text", text: "benchmark output" }], + }, + { role: "developer", content: advisory }, + { role: "user", content: "continue" }, + ], + }) + + await collectEvents(streamCommandCode(makeModel(), context, { apiKey: "mock-key" })) + + const body = server.lastRequestBody() + assert.deepEqual( + objectAt(body, ["params", "messages", "3"]), + { role: "user", content: advisory }, + "developer advisory should arrive as an in-position user message with identical content", + ) + assert.deepEqual(objectAt(body, ["params", "messages", "4"]), { + role: "user", + content: "continue", + }) + assert.equal(objectAt(body, ["params", "messages", "5"]), undefined) + assert.equal( + objectAt(body, ["params", "system"]), + "You are a test assistant.", + "advisory must not be hoisted into the system prompt", + ) + assert.doesNotMatch(String(objectAt(body, ["params", "system"])), /advisory/) + }) + it("accepts the legacy OMP nested reasoning map", async () => { server.mockResponse({ type: "success",