From 16eb5a8ddaf80a23fde77373932be9a3956d507e Mon Sep 17 00:00:00 2001 From: warc0s Date: Mon, 17 Aug 2026 09:03:32 +0200 Subject: [PATCH 1/2] 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", From 1f312f8d61514270066263446efeeaaa369b8261 Mon Sep 17 00:00:00 2001 From: warc0s Date: Mon, 17 Aug 2026 09:04:32 +0200 Subject: [PATCH 2/2] test(omp): cover developer advisory delivery Add a fixture extension that injects an advisor-style custom message on session start, and assert the advisory XML reaches params.messages verbatim, in chronological position, and is not hoisted into params.system. Port the models phase to `omp models --json`. `--list-models` no longer exists in current OMP (verified on 17.3.5), so the phase failed before reaching print mode. --- tests/fixtures/advisory-injector-extension.ts | 41 ++++++++ tests/test-omp-compat.mjs | 99 +++++++++++++++++-- 2 files changed, 130 insertions(+), 10 deletions(-) create mode 100644 tests/fixtures/advisory-injector-extension.ts diff --git a/tests/fixtures/advisory-injector-extension.ts b/tests/fixtures/advisory-injector-extension.ts new file mode 100644 index 0000000..f88d5ed --- /dev/null +++ b/tests/fixtures/advisory-injector-extension.ts @@ -0,0 +1,41 @@ +/** + * Minimal OMP extension used by tests/test-omp-compat.mjs. + * + * Appends a custom advisor message to the session on session start, mirroring + * how the OMP Advisor runtime injects steering notes. OMP converts custom + * messages to `role: "developer"` LLM messages before handing them to the + * provider. Types are declared inline so the fixture has no runtime or + * typecheck dependency on OMP packages. + */ + +interface AdvisorInjectorSendMessage { + ( + message: { + customType: string + content: string + display: boolean + attribution: string + }, + options?: { triggerTurn?: boolean }, + ): void +} + +interface AdvisorInjectorApi { + on: (event: "session_start", handler: () => void | Promise) => void + sendMessage: AdvisorInjectorSendMessage +} + +export default function advisoryInjectorExtension(pi: AdvisorInjectorApi): void { + pi.on("session_start", async () => { + pi.sendMessage( + { + customType: "advisor", + content: + '\nStop and correct the benchmark.\n', + display: true, + attribution: "agent", + }, + { triggerTurn: false }, + ) + }) +} diff --git a/tests/test-omp-compat.mjs b/tests/test-omp-compat.mjs index af7c2bd..04f8c78 100644 --- a/tests/test-omp-compat.mjs +++ b/tests/test-omp-compat.mjs @@ -19,7 +19,10 @@ import { fileURLToPath } from "node:url" const __dirname = dirname(fileURLToPath(import.meta.url)) const PROJECT_DIR = resolve(__dirname, "..") const EXT_PATH = resolve(PROJECT_DIR, "index.ts") +const ADVISORY_EXT_PATH = resolve(PROJECT_DIR, "tests/fixtures/advisory-injector-extension.ts") const TEST_MODEL = "deepseek/deepseek-v4-flash" +const ADVISORY_XML = + '\nStop and correct the benchmark.\n' function findOmpBinary() { if (process.env.OMP_BIN) return process.env.OMP_BIN @@ -45,6 +48,7 @@ const tempHome = mkdtempSync(join(tmpdir(), "omp-cc-home-")) let requestCount = 0 let modelListRequestCount = 0 let lastRequestBody +let requestBodies = [] let lastRequestHeaders = {} const server = createServer((req, res) => { @@ -98,6 +102,7 @@ const server = createServer((req, res) => { req.on("end", () => { try { lastRequestBody = JSON.parse(body) + requestBodies.push(lastRequestBody) } catch { lastRequestBody = undefined } @@ -160,19 +165,33 @@ function runOmp(args, timeoutMs = 30_000) { try { console.log("[omp-compat] list models through real extension") modelListRequestCount = 0 - const result = await runOmp(["-e", EXT_PATH, "--list-models"]) - assert.equal(result.code, 0, result.stderr) - const listOutput = result.stdout || result.stderr - assert.match(listOutput, /commandcode/) - assert.match(listOutput, /deepseek\/deepseek-v4-flash/) - assert.equal(modelListRequestCount, 1) - assert.doesNotThrow(() => - accessSync(join(tempHome, ".omp", "agent", "commandcode-models.json"), constants.R_OK), - ) - assert.doesNotMatch(result.stdout + result.stderr, /Failed to load extension/) + const list = await runOmp(["models", "--json", "-e", EXT_PATH, "--no-extensions"]) + if (list.code !== 0 && /unknown|unrecognized/i.test(list.stderr + list.stdout)) { + console.log("[omp-compat] SKIP models phase - omp models subcommand unavailable") + } else { + assert.equal(list.code, 0, list.stderr) + let listed = null + try { + listed = JSON.parse(list.stdout) + } catch { + listed = null + } + const models = Array.isArray(listed?.models) ? listed.models : [] + assert.ok( + models.some((model) => model.provider === "commandcode"), + "commandcode provider should be listed", + ) + assert.ok( + models.some((model) => model.id === TEST_MODEL), + "mock catalog model should be listed", + ) + assert.ok(modelListRequestCount >= 1) + assert.doesNotMatch(list.stdout + list.stderr, /Failed to load extension/) + } console.log("[omp-compat] print mode through real extension and mock API") requestCount = 0 + requestBodies = [] const print = await runOmp( ["-e", EXT_PATH, "-p", "say mock token", "--model", `commandcode/${TEST_MODEL}`], 30_000, @@ -187,6 +206,66 @@ try { ) assert.equal(lastRequestBody?.params?.model, TEST_MODEL) assert.equal(typeof lastRequestBody?.params?.system, "string") + assert.doesNotThrow(() => + accessSync(join(tempHome, ".omp", "agent", "commandcode-models.json"), constants.R_OK), + ) + + console.log("[omp-compat] developer advisory reaches the provider request body") + requestCount = 0 + requestBodies = [] + const advisoryRun = await runOmp( + [ + "-e", + EXT_PATH, + "-e", + ADVISORY_EXT_PATH, + "-p", + "say mock token", + "--model", + `commandcode/${TEST_MODEL}`, + "--no-tools", + "--no-title", + ], + 30_000, + ) + assert.equal(advisoryRun.code, 0, advisoryRun.stderr) + assert.match(advisoryRun.stdout, /mock-omp-ok/) + + const promptBodies = requestBodies.filter((body) => + JSON.stringify(body?.params?.messages ?? []).includes("say mock token"), + ) + assert.ok(promptBodies.length >= 1, "expected at least one generate request with the prompt") + + for (const body of promptBodies) { + const messages = body?.params?.messages ?? [] + const advisoryMessages = messages.filter((message) => + JSON.stringify(message).includes("Stop and correct the benchmark."), + ) + assert.equal(advisoryMessages.length, 1, "the advisory should survive conversion exactly once") + const advisoryMessage = advisoryMessages[0] + assert.equal(advisoryMessage.role, "user") + const advisoryText = + typeof advisoryMessage.content === "string" + ? advisoryMessage.content + : (advisoryMessage.content ?? []) + .map((part) => (part?.type === "text" ? part.text : "")) + .join("\n") + assert.equal(advisoryText, ADVISORY_XML, "advisory content must arrive verbatim") + + const advisoryIndex = messages.indexOf(advisoryMessage) + const promptIndex = messages.findIndex((message) => + JSON.stringify(message).includes("say mock token"), + ) + assert.ok( + advisoryIndex < promptIndex, + "advisory must keep its chronological position relative to the prompt", + ) + assert.doesNotMatch( + String(body?.params?.system ?? ""), + /Stop and correct the benchmark|