From 8c9020c58f18dfa88e2797af32d066073a4bc8ed Mon Sep 17 00:00:00 2001 From: Eridanus <45489268+Eridanus117@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:11:06 +0800 Subject: [PATCH] fix: keep flat Context prompt and tools when the transcript has no system message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #37 omp 18.12.3 exports the full pi-ai transcript toolkit to legacy extensions yet still calls `stream` with a flat Context, so `systemPrompt` and `tools` arrive unfolded. `hasTranscript` keyed off helper presence alone, so the transcript path ran, `getCurrentTools` found no system message to read `toolsAdded` from, and every request shipped `tools: []` — the model then answered with DSML text markup and the host dropped the turn as empty. Take the transcript path only when the collapsed transcript actually has a leading system message; otherwise keep the flat fields. --- src/__tests__/stream.test.ts | 20 ++++++++++++++++++++ src/protocol/stream.ts | 30 ++++++++++++++++++++++++------ 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/src/__tests__/stream.test.ts b/src/__tests__/stream.test.ts index 3ebf7e4..bb45ec0 100644 --- a/src/__tests__/stream.test.ts +++ b/src/__tests__/stream.test.ts @@ -645,6 +645,26 @@ describe("resolveRequestContext", () => { expect(resolved.tools.map((t) => t.name)).toEqual(["bash"]); }); + it("keeps flat Context tools when the host exports the toolkit but passes a flat Context (omp 18.12.x)", () => { + // omp 18.12.3 exports the whole transcript toolkit to legacy extensions yet + // still calls `stream` with `systemPrompt` / `tools` unfolded. Replaying that + // transcript finds no system message, so the resolver must use the flat + // fields instead of shipping `tools: []` (the model then invents DSML text + // markup and every turn ends empty). + const resolved = resolveRequestContext(PiAi, flatContext); + expect(resolved.systemText).toBe("legacy prompt"); + expect(resolved.tools.map((t) => t.name)).toEqual(["bash"]); + expect(resolved.messages).toBe(flatContext.messages); + }); + + it("keeps the flat prompt when a flat Context carries no tools", () => { + const noTools = { ...flatContext, tools: [] } as unknown as Context; + const resolved = resolveRequestContext(PiAi, noTools); + expect(resolved.systemText).toBe("legacy prompt"); + expect(resolved.tools).toEqual([]); + expect(resolved.messages).toBe(noTools.messages); + }); + it("resolves tools + prompt from the transcript when helpers are present (pi-ai >=0.86)", () => { const transcript = PiAi.normalizeContext({ systemPrompt: "you are helpful", diff --git a/src/protocol/stream.ts b/src/protocol/stream.ts index 2e26983..0a1ba4d 100644 --- a/src/protocol/stream.ts +++ b/src/protocol/stream.ts @@ -115,11 +115,17 @@ type TranscriptHelpers = Partial< * message, because Qoder ignores the top-level `system` field). * - pi-ai <=0.85 hands providers a flat `Context` with top-level * `systemPrompt` / `tools` and no transcript helpers. + * - Some >=0.86 hosts hand providers a flat `Context` anyway while still + * exporting the transcript toolkit to legacy extensions (omp 18.12.x does: + * its compat root exports the helpers, but `streamQoder` receives + * `systemPrompt` + `tools` unfolded). The leading system message is what + * marks a folded transcript, so its absence means the flat fields still + * carry the prompt and tools. * * The helpers are passed in (as the pi-ai namespace) and feature-detected, so a - * single bundle runs on both: on 0.85 the namespace simply lacks them and we use - * the flat fields. Using a namespace import for this is deliberate — a named - * import of a 0.86-only export would throw at module-load time on 0.85. + * single bundle runs on all three: on 0.85 the namespace simply lacks them and + * we use the flat fields. Using a namespace import for this is deliberate — a + * named import of a 0.86-only export would throw at module-load time on 0.85. */ export function resolveRequestContext( piAi: TranscriptHelpers, @@ -158,10 +164,22 @@ export function resolveRequestContext( const collapsed = collapseSystemMessages(context as unknown as TranscriptContext); const transcriptMessages = collapsed.messages; const initialSystem = getInitialSystemMessage(transcriptMessages); + + // The leading system message is the only proof that this host folded + // `systemPrompt` and `tools` into the transcript. Without it the helpers + // replay nothing — `getCurrentTools` reads `toolsAdded` off system messages — + // so a host that exports the toolkit but still passes a flat `Context` would + // otherwise send `tools: []` and lose the tool-call protocol entirely. + if (!initialSystem) { + return { + messages: context.messages, + systemText: contentToText(context.systemPrompt || ""), + tools: context.tools ?? [], + }; + } + const tools = getCurrentTools(transcriptMessages); - const systemText = contentToText( - initialSystem && getSystemMessageText ? getSystemMessageText(initialSystem) : context.systemPrompt || "", - ); + const systemText = contentToText(getSystemMessageText?.(initialSystem) ?? context.systemPrompt ?? ""); const messages = withoutInitialSystemMessage(transcriptMessages); return { messages, systemText, tools }; }