Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions next/src/lib/agents/__tests__/argv.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import { parseLine, makeParser } from "../argv";

function deltas(result: ReturnType<typeof parseLine>) {
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"]);
});
});
8 changes: 8 additions & 0 deletions next/src/lib/agents/argv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseLineWithState now handles the live part.text shape, but this regression path still has no parser-level test coverage. That matters here because this file is the only contract between the agent CLIs and /api/draft//api/convert; without a fixture that exercises empty top-level text/content/message plus singular part.text and array parts[].text, a later refactor can silently reintroduce the exact start -> done no-output failure this PR is fixing. Please add a small regression matrix around parseLine('opencode', ...)/parseLine('qwen', ...) (for example in a new next/src/lib/agents/argv.test.ts) that asserts these envelopes emit the expected delta chunks.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

Comment on lines +374 to +375

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These new obj.part?.text / obj.part.text reads do not type-check against the file's parsed Record<string, unknown> shape. On this head, pnpm -F @html-anything/next typecheck fails with TS2339: Property 'text' does not exist on type '{}' on lines 374-375, so the PR still breaks the package's required compile gate even though the runtime regression tests pass. Please narrow obj.part to an object type before reading text here (for example by assigning it to a local, checking typeof part === "object", and then casting to { text?: unknown }) so the parser fix compiles cleanly as well as running correctly.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

}
}

if (agent === "qoder") {
Expand Down