| 1 | // @effect-diagnostics nodeBuiltinImport:off |
| 2 | import * as NodeFS from "node:fs"; |
| 3 | import * as NodeOS from "node:os"; |
| 4 | import * as NodePath from "node:path"; |
| 5 | import { it as effectIt } from "@effect/vitest"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; |
| 8 | |
| 9 | import { parseWorkflowAgentAnswers, readWorkflowAgentAnswers } from "./workflowAgentAnswers.ts"; |
| 10 | |
| 11 | const line = (value: unknown) => `${JSON.stringify(value)}\n`; |
| 12 | |
| 13 | const assistant = (id: string, content: ReadonlyArray<unknown>) => |
| 14 | line({ type: "assistant", message: { role: "assistant", id, content } }); |
| 15 | |
| 16 | describe("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. |
| 80 | let configDir: string; |
| 81 | let transcriptDir: string; |
| 82 | beforeAll(() => { |
| 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 | |
| 88 | afterAll(() => { |
| 89 | NodeFS.rmSync(configDir, { recursive: true, force: true }); |
| 90 | }); |
| 91 | |
| 92 | describe("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 | }); |