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",