diff --git a/next/src/lib/agents/__tests__/argv.test.ts b/next/src/lib/agents/__tests__/argv.test.ts new file mode 100644 index 0000000..28cefc9 --- /dev/null +++ b/next/src/lib/agents/__tests__/argv.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { parseLine, makeParser } from "../argv"; + +function deltas(result: ReturnType) { + return result.filter((r) => r.kind === "delta").map((r) => r.text); +} + +describe("parseLine (opencode)", () => { + it("extracts text from singular part.text envelope", () => { + const line = JSON.stringify({ + type: "text", + part: { type: "text", text: "hello world" }, + }); + expect(deltas(parseLine("opencode", line))).toEqual(["hello world"]); + }); + + it("extracts text from parts[].text array envelope", () => { + const line = JSON.stringify({ + type: "text", + parts: [ + { type: "text", text: "hello " }, + { type: "text", text: "world" }, + ], + }); + expect(deltas(parseLine("opencode", line))).toEqual(["hello ", "world"]); + }); + + it("extracts text from top-level text field", () => { + const line = JSON.stringify({ text: "top-level content" }); + expect(deltas(parseLine("opencode", line))).toContain("top-level content"); + }); + + it("extracts text from top-level content field", () => { + const line = JSON.stringify({ content: "content field" }); + expect(deltas(parseLine("opencode", line))).toContain("content field"); + }); + + it("extracts text from top-level message field", () => { + const line = JSON.stringify({ message: "message field" }); + expect(deltas(parseLine("opencode", line))).toContain("message field"); + }); + + it("produces no non-empty delta when all text fields are empty", () => { + const line = JSON.stringify({ + type: "text", + text: "", + content: "", + message: "", + part: { type: "text", text: "" }, + }); + expect(deltas(parseLine("opencode", line)).filter(Boolean)).toEqual([]); + }); + + it("handles empty top-level fields with content in part.text (regression #67)", () => { + const line = JSON.stringify({ + type: "text", + text: "", + content: "", + message: "", + part: { + type: "text", + text: "# 春之声\n\n三月的风是软的。", + }, + }); + expect(deltas(parseLine("opencode", line))).toContain( + "# 春之声\n\n三月的风是软的。", + ); + }); +}); + +describe("parseLine (qwen)", () => { + it("extracts text from singular part.text envelope", () => { + const line = JSON.stringify({ + type: "text", + part: { type: "text", text: "qwen output" }, + }); + expect(deltas(parseLine("qwen", line))).toEqual(["qwen output"]); + }); + + it("extracts text from parts[].text array envelope", () => { + const line = JSON.stringify({ + parts: [{ text: "a" }, { text: "b" }], + }); + expect(deltas(parseLine("qwen", line))).toEqual(["a", "b"]); + }); +}); + +describe("makeParser (opencode) multi-line streaming", () => { + it("accumulates deltas across multiple lines", () => { + const parse = makeParser("opencode"); + parse( + JSON.stringify({ type: "step_start", part: { type: "step-start" } }), + ); + const r2 = parse( + JSON.stringify({ type: "text", part: { type: "text", text: "hello" } }), + ); + const r3 = parse( + JSON.stringify({ + type: "text", + part: { type: "text", text: " world" }, + }), + ); + expect(deltas(r2)).toEqual(["hello"]); + expect(deltas(r3)).toEqual([" world"]); + }); +}); diff --git a/next/src/lib/agents/argv.ts b/next/src/lib/agents/argv.ts index c6cc169..21d3b14 100644 --- a/next/src/lib/agents/argv.ts +++ b/next/src/lib/agents/argv.ts @@ -366,6 +366,14 @@ function parseLineWithState(agent: string, line: string, state: ParseState): Age if (typeof obj.text === "string") out.push({ kind: "delta", text: obj.text }); if (typeof obj.content === "string") out.push({ kind: "delta", text: obj.content }); if (typeof obj.message === "string") out.push({ kind: "delta", text: obj.message }); + if (Array.isArray(obj.parts)) { + for (const part of obj.parts) { + if (typeof part?.text === "string") out.push({ kind: "delta", text: part.text }); + } + } + if (obj.type === "text" && typeof obj.part?.text === "string") { + out.push({ kind: "delta", text: obj.part.text }); + } } if (agent === "qoder") {