diff --git a/src/adapters/opencode/plugin.ts b/src/adapters/opencode/plugin.ts index 5459359c..fc55933e 100644 --- a/src/adapters/opencode/plugin.ts +++ b/src/adapters/opencode/plugin.ts @@ -700,13 +700,23 @@ async function createContextModePlugin(ctx: PluginContext) { if (Array.isArray(output?.system)) { if (!systemHasRoutingInstructions(output.system)) { try { - output.system.splice(1, 0, routingBlock); + // Append to last system entry instead of splice(1, 0). OpenCode + // serializes system[] as separate role:system messages for strict + // OpenAI-compatible providers (NaN, etc.) that only accept ONE + // system message at index 0. See PR #XXX. + // Cache-fold (system[0] invariant) is traded off for cross-provider + // compatibility. Anthropic/Moonshot are unaffected. (PR #XXX) + if (output.system.length > 0) { + output.system[output.system.length - 1] += "\n\n" + routingBlock; + } else { + output.system.push(routingBlock); + } } catch { // Never break the chat turn on routing-block injection failure. } if (process.env.OPENCODE_DEBUG) { - await safeLog(output.system[1], {sessionId, source: 'on routing block injection'}); + await safeLog(routingBlock, {sessionId, source: 'on routing block injection (appended to system[last])'}); } } else if (process.env.OPENCODE_DEBUG) { await safeLog(`routing block skipped — system prompt already contains context-mode instructions`, {sessionId, source: 'on routing block injection'}); @@ -728,20 +738,17 @@ async function createContextModePlugin(ctx: PluginContext) { } if (Array.isArray(output?.system)) { - // Insert at index 1 (after the header) — NOT unshift. - // OpenCode's llm.ts:117-128 saves `header = system[0]` BEFORE this - // hook runs and then folds the rest into a 2-part structure - // `[header, body]` only if `system[0] === header` after the hook. - // Prepending via unshift replaces system[0] with the snapshot, - // making the equality check fail → cache-fold is skipped → every - // system block is sent as a separate `role: "system"` message → - // provider prompt cache is invalidated on every resume injection. - // Inserting at index 1 keeps the header invariant and lets the - // snapshot ride along inside the cached body block. - output.system.splice(1, 0, row.snapshot); - // Mark consumed only AFTER successful splice so failed paths can retry + // Append to last system entry instead of splice(1, 0). + // Same principle as the routing block: compatibility with + // strict OpenAI-compatible providers. + if (output.system.length > 0) { + output.system[output.system.length - 1] += "\n\n" + row.snapshot; + } else { + output.system.push(row.snapshot); + } + // Mark consumed only AFTER successful injection so failed paths can retry if (process.env.OPENCODE_DEBUG) { - await safeLog(output.system[1], { sessionId, source: "on resume" }); + await safeLog(row.snapshot, { sessionId, source: "on resume (appended to system[last])" }); } } } catch { diff --git a/tests/opencode-plugin.test.ts b/tests/opencode-plugin.test.ts index 86424cac..c1493518 100644 --- a/tests/opencode-plugin.test.ts +++ b/tests/opencode-plugin.test.ts @@ -625,9 +625,9 @@ describe("ContextModePlugin", () => { { sessionID: "fresh-session", model: {} } as any, out, ); - expect(out.system[0]).toBe("HEADER"); // header preserved - expect(out.system.length).toBe(2); // header + routing block (no resume) - expect(out.system[1]).toContain(""); + expect(out.system[0]).toContain("HEADER"); // header exists + routing appended + expect(out.system.length).toBe(1); // HEADER concatenated (routing appended) + expect(out.system[0]).toContain(""); expect(out.system.join("\n")).not.toContain("session_resume"); }); @@ -654,8 +654,8 @@ describe("ContextModePlugin", () => { { sessionID: "new-session", model: {} } as any, out, ); - expect(out.system[0]).toBe("HEADER"); - expect(out.system.length).toBe(3); // HEADER + routing + snapshot + expect(out.system[0]).toContain("HEADER"); + expect(out.system.length).toBe(1); // HEADER with routing + snapshot appended expect(out.system.some((s) => s.includes("session_resume"))).toBe(true); expect(out.system.some((s) => s.includes(""))).toBe(true); }); @@ -691,12 +691,12 @@ describe("ContextModePlugin", () => { ); // The snapshot was inserted, but header at index 0 is preserved // exactly as OpenCode saw it before the hook. - expect(out.system[0]).toBe(HEADER); - expect(out.system[out.system.length - 1]).toBe(BODY); - expect(out.system.length).toBe(4); // HEADER + routing + snapshot + BODY - const middle = out.system.slice(1, -1).join("\n"); - expect(middle).toContain("session_resume"); - expect(middle).toContain(""); + expect(out.system[0]).toContain(HEADER); + expect(out.system[out.system.length - 1]).toContain(BODY); + expect(out.system.length).toBe(2); // HEADER + BODY, routing & snapshot appended to BODY + const joined = out.system.join("\n"); + expect(joined).toContain("session_resume"); + expect(joined).toContain(""); }); it("does NOT re-inject resume snapshot on second call with the same sessionID (multi-turn)", async () => { @@ -719,7 +719,7 @@ describe("ContextModePlugin", () => { out1, ); // First turn: HEADER + routing + snapshot - expect(out1.system.length).toBe(3); + expect(out1.system.length).toBe(1); const out2 = { system: ["HEADER"] }; await plugin["experimental.chat.system.transform"]( @@ -727,8 +727,8 @@ describe("ContextModePlugin", () => { out2, ); // Same session — resume snapshot consumed from DB. Routing block re-injects (no dedup). - expect(out2.system.length).toBe(2); // HEADER + routing block - expect(out2.system[1]).toContain(""); + expect(out2.system.length).toBe(1); // HEADER with routing appended + expect(out2.system[0]).toContain(""); expect(out2.system.join("\n")).not.toContain("session_resume"); }); @@ -757,9 +757,9 @@ describe("ContextModePlugin", () => { out, ); // No resume snapshot for B (self-inject guard) but routing block lands. - expect(out.system.length).toBe(3); - expect(out.system[0]).toBe("HEADER"); - expect(out.system[2]).toBe("BODY"); + expect(out.system.length).toBe(2); // HEADER + BODY with routing appended to BODY + expect(out.system[0]).toContain("HEADER"); + expect(out.system[1]).toContain("BODY"); expect(out.system.join("\n")).not.toContain("session_resume"); expect(out.system[1]).toContain(""); }); @@ -777,7 +777,7 @@ describe("ContextModePlugin", () => { { sessionID: "C", model: {} } as any, out1, ); - expect(out1.system.length).toBe(2); // HEADER + routing block + expect(out1.system.length).toBe(1); // HEADER with routing appended expect(out1.system.join("\n")).not.toContain("session_resume"); // Now a different session compacts and produces a snapshot @@ -797,9 +797,9 @@ describe("ContextModePlugin", () => { { sessionID: "C", model: {} } as any, out2, ); - expect(out2.system.length).toBe(3); // HEADER + snapshot + routing - expect(out2.system[1]).toContain("session_resume"); - expect(out2.system[2]).toContain(""); + expect(out2.system.length).toBe(1); // HEADER with snapshot + routing appended + expect(out2.system[0]).toContain("session_resume"); + expect(out2.system[0]).toContain(""); }); // v1.0.106 — prefer next session over self-injection @@ -823,9 +823,9 @@ describe("ContextModePlugin", () => { { sessionID: "B", model: {} } as any, outB, ); - expect(outB.system.length).toBe(2); + expect(outB.system.length).toBe(1); expect(outB.system.join("\n")).not.toContain("session_resume"); - expect(outB.system[1]).toContain(""); + expect(outB.system[0]).toContain(""); // C asks — gets B's snapshot AND routing block (both first-fire for C) const outC = { system: ["HEADER"] }; @@ -833,7 +833,7 @@ describe("ContextModePlugin", () => { { sessionID: "C", model: {} } as any, outC, ); - expect(outC.system.length).toBe(3); // HEADER + routing + expect(outC.system.length).toBe(1); // HEADER with routing + snapshot appended expect(outC.system.some((s) => s.includes("session_resume"))).toBe(true); }); @@ -856,7 +856,7 @@ describe("ContextModePlugin", () => { out, ); // v1.0.107 — out.system is [HEADER, routing-block] - expect(out.system.length).toBe(3); + expect(out.system.length).toBe(1); // HEADER with routing + snapshot const snapshotEntry = out.system.find((s) => s.includes("session_resume")); expect(snapshotEntry).toBeDefined(); }); @@ -903,9 +903,9 @@ describe("ContextModePlugin", () => { { sessionID: "oc1-fresh", model: {} } as any, out, ); - // header preserved at index 0 (cache-fold invariant) - expect(out.system[0]).toBe("HEADER"); - // routing block spliced at index 1 + // header preserved via concatenation (content merged into existing entries) + expect(out.system[0]).toContain("HEADER"); + // routing block appended to last system entry const joined = out.system.join("\n"); expect(joined).toContain(""); expect(joined).toContain(""); @@ -929,7 +929,7 @@ describe("ContextModePlugin", () => { ); // Routing block injects every turn for reliability (no dedup set). expect(out2.system.join("\n")).toContain(""); - expect(out2.system.length).toBe(2); + expect(out2.system.length).toBe(1); }); it("OC-1: skips routing block when system prompt already contains context-mode instructions", async () => { @@ -953,7 +953,7 @@ describe("ContextModePlugin", () => { // itself contains , so we assert by structure // (no new entry spliced in) rather than substring absence. expect(out.system.length).toBe(2); - expect(out.system[0]).toBe("HEADER"); + expect(out.system[0]).toContain("HEADER"); expect(out.system[1]).toBe(agentsContent); }); @@ -968,7 +968,7 @@ describe("ContextModePlugin", () => { { sessionID: "oc1-quorum-sess", model: {} } as any, out, ); - expect(out.system.length).toBe(3); // HEADER + routing + partialContent + expect(out.system.length).toBe(2); // HEADER + partialContent with routing appended expect(out.system[1]).toContain(""); }); }); @@ -1298,7 +1298,7 @@ describe("ContextModePlugin", () => { ).resolves.not.toThrow(); // Routing block still spliced at index 1 (turn-break would skip this) - expect(out.system[0]).toBe("HEADER"); + expect(out.system[0]).toContain("HEADER"); expect(out.system.join("\n")).toContain(""); });