1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeFS from "node:fs";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5import { it as effectIt } from "@effect/vitest";
6import * as Effect from "effect/Effect";
7import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test";
8
9import { parseWorkflowAgentAnswers, readWorkflowAgentAnswers } from "./workflowAgentAnswers.ts";
10
11const line = (value: unknown) => `${JSON.stringify(value)}\n`;
12
13const assistant = (id: string, content: ReadonlyArray<unknown>) =>
14 line({ type: "assistant", message: { role: "assistant", id, content } });
15
16describe("parseWorkflowAgentAnswers", () => {
17 it("returns one entry per assistant turn", () => {
18 expect(
19 parseWorkflowAgentAnswers(
20 assistant("a", [{ type: "text", text: "first" }]) +
21 assistant("b", [{ type: "text", text: "second" }]),
22 ),
23 ).toEqual(["first", "second"]);
24 });
25
26 it("joins the lines of one split assistant message into a single turn", () => {
27 expect(
28 parseWorkflowAgentAnswers(
29 assistant("a", [{ type: "thinking", thinking: "weighing it up" }]) +
30 assistant("a", [{ type: "text", text: "the answer" }]) +
31 assistant("a", [{ type: "text", text: "and more" }]),
32 ),
33 ).toEqual(["the answer\n\nand more"]);
34 });
35
36 it("ignores harness bookkeeping and the member's own prompt", () => {
37 expect(
38 parseWorkflowAgentAnswers(
39 line({ type: "attachment", attachment: { type: "skill_listing", content: "- adhd" } }) +
40 line({ type: "user", message: { role: "user", content: "the prompt" } }) +
41 line({
42 type: "assistant",
43 isMeta: true,
44 message: { role: "assistant", id: "m", content: [{ type: "text", text: "injected" }] },
45 }) +
46 assistant("a", [{ type: "text", text: "real answer" }]),
47 ),
48 ).toEqual(["real answer"]);
49 });
50
51 it("skips a turn that produced no text", () => {
52 expect(
53 parseWorkflowAgentAnswers(
54 assistant("a", [{ type: "tool_use", id: "t", name: "Bash", input: {} }]),
55 ),
56 ).toEqual([]);
57 });
58
59 it("preserves answer indentation and ignores blank text", () => {
60 expect(
61 parseWorkflowAgentAnswers(
62 assistant("a", [{ type: "text", text: " indented code" }]) +
63 assistant("b", [{ type: "text", text: " " }]),
64 ),
65 ).toEqual([" indented code"]);
66 });
67
68 it("survives a trailing line the byte cap cut in half", () => {
69 expect(
70 parseWorkflowAgentAnswers(
71 assistant("a", [{ type: "text", text: "complete" }]) + '{"type":"assistant","mess',
72 ),
73 ).toEqual(["complete"]);
74 });
75});
76
77// Transcripts must sit under <configDir>/projects: readContainedWorkflowFile
78// rejects any path outside it. A temporary configDir keeps the real
79// ~/.claude untouched.
80let configDir: string;
81let transcriptDir: string;
82beforeAll(() => {
83 configDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "wf-config-"));
84 transcriptDir = NodePath.join(configDir, "projects", "test");
85 NodeFS.mkdirSync(transcriptDir, { recursive: true });
86});
87
88afterAll(() => {
89 NodeFS.rmSync(configDir, { recursive: true, force: true });
90});
91
92describe("readWorkflowAgentAnswers", () => {
93 // The under-cap case is the control: without it a read stuck on [] would pass.
94 effectIt.effect("reads final answers from both small and capped transcripts", () =>
95 Effect.gen(function* () {
96 const turn = assistant("a", [{ type: "text", text: "the answer" }]);
97 const filler = line({ type: "user", message: { role: "user", content: "x".repeat(4096) } });
98 const write = (agentId: string, contents: string) =>
99 NodeFS.writeFileSync(NodePath.join(transcriptDir, `agent-${agentId}.jsonl`), contents);
100 const read = (agentId: string) =>
101 readWorkflowAgentAnswers({ transcriptDir, agentId, configDir });
102 write("small", turn);
103 const edgeAnswer = "x".repeat(
104 512 * 1024 + 1 - Buffer.byteLength(assistant("edge", [{ type: "text", text: "" }])),
105 );
106 write("edge", assistant("edge", [{ type: "text", text: edgeAnswer }]));
107 write(
108 "boundary",
109 filler.repeat(160) + turn + " ".repeat(512 * 1024 - Buffer.byteLength(turn)),
110 );
111 write(
112 "huge",
113 turn + filler.repeat(160) + assistant("final", [{ type: "text", text: "final answer" }]),
114 );
115 expect(yield* read("small")).toEqual(["the answer"]);
116 expect(yield* read("edge")).toEqual([edgeAnswer]);
117 expect(yield* read("huge")).toEqual(["final answer"]);
118 expect(yield* read("boundary")).toEqual(["the answer"]);
119 }),
120 );
121});