import { describe, expect, it } from "bun:test"; import { adaptiveMaxTokens, conversationChars, ErrorTracker, truncateToolOutput, bashExitCode, isToolFailure, deriveVerifyCommand, AgentTurnServiceImpl, takeRecentTail, reduceMessagesToDigest, compactMessagesWithAi, } from "./turn_service.ts"; import { type ChatMessage, newConversation, systemMessage, userMessage, assistantMessage, toolResultMessage, } from "@zesdex/domain"; import type { AgentTurnParams } from "@zesdex/agent"; import type { ToolExecutor } from "./index.ts"; import type { ProviderService } from "./ports.ts"; describe("truncateToolOutput", () => { it("short output is unchanged", () => { expect(truncateToolOutput("short")).toBe("short"); }); it("long output preserves head and marks cut", () => { const long = "x".repeat(12_000 + 500); const truncated = truncateToolOutput(long); expect(truncated.length).toBeLessThan(long.length); expect(truncated).toContain("...[truncated"); expect(truncated.startsWith("xxx")).toBe(true); }); }); describe("adaptiveMaxTokens", () => { it("scales with request length", () => { expect(adaptiveMaxTokens(10)).toBe(800); expect(adaptiveMaxTokens(200)).toBe(1600); expect(adaptiveMaxTokens(5000)).toBe(4096); }); }); describe("ErrorTracker", () => { it("injects recovery note after repeated errors", () => { const tracker = new ErrorTracker(); const messages: ChatMessage[] = []; tracker.record("read", "Error: File not found", messages); tracker.record("read", "Error: File not found", messages); expect(tracker.shouldStop()).toBe(false); tracker.record("read", "Error: File not found", messages); expect(messages.some((m) => m.content?.includes("[System note]"))).toBe(true); }); it("stops after too many errors", () => { const tracker = new ErrorTracker(); const messages: ChatMessage[] = []; for (let i = 0; i < 8; i++) { tracker.record("bash", `Error: boom ${i}`, messages); } expect(tracker.shouldStop()).toBe(true); }); }); describe("conversationChars", () => { it("sums content only", () => { const conv = newConversation("sys", "s1"); conv.messages.push(systemMessage("sys"), userMessage("hello world"), toolResultMessage("id", "output")); expect(conversationChars(conv.messages)).toBe(3 + 11 + 6); }); }); /* ── New flow helpers ─────────────────────────────────────────────── */ describe("bashExitCode / isToolFailure", () => { it("extracts a non-zero exit code", () => { expect(bashExitCode("boom\n\nExit code: 1 (1s)")).toBe(1); expect(bashExitCode("done\n\nExit code: 0 (0.5s)")).toBe(0); }); it("returns null when no exit-code line exists", () => { expect(bashExitCode("plain output")).toBeNull(); }); it("isToolFailure flags bash non-zero exits as failures", () => { expect(isToolFailure("bash", "nope\n\nExit code: 2 (1s)")).toBe(true); expect(isToolFailure("bash", "ok\n\nExit code: 0 (1s)")).toBe(false); }); it("isToolFailure still flags Error: prefixes for other tools", () => { expect(isToolFailure("read", "Error: no such file")).toBe(true); }); }); describe("deriveVerifyCommand", () => { it("defaults to bun check+test for a Bun repo", () => { // No real manifests in a control dir we guarantee to not exist. expect(deriveVerifyCommand("/nonexistent-zesdex-dir")).toContain("bun"); }); }); /* ── runTurn flow tests (fakes, no network) ───────────────────────── */ interface ScriptedStep { content?: string | null; tools?: Array<{ name: string; args?: string; id?: string }>; } /** A ProviderService that replays scripted chatStream responses. */ function makeScriptedProvider(script: ScriptedStep[]): { provider: ProviderService; seen: Array; chatCalls: Array; } { const seen: Array = []; const chatCalls: Array = []; let step = 0; const chatResponses: string[] = []; const provider: ProviderService = { async chat(messages, _tools, _maxTokens, _temperature) { // Used by compaction and review. For review tests we script below. chatCalls.push([...messages]); const text = chatResponses.shift() ?? ""; return { message: { role: "assistant", content: text || null }, usage: [10, 5] }; }, async chatStream(messages, _tools, _max, _temp, onEvent) { // Snapshot a copy: runTurn freely mutates the live array (splice/compact). seen.push([...messages]); const s = script[Math.min(step, script.length - 1)] ?? { content: "done" }; step += 1; if (s.content !== undefined && s.content !== null) onEvent({ kind: "token", content: s.content }); if (s.tools && s.tools.length > 0) { const msg = assistantMessage(null) as ChatMessage; msg.tool_calls = s.tools.map((t, i) => ({ id: t.id ?? `call-${i}`, type: "function", function: { name: t.name, arguments: t.args ?? "{}" }, })); return { message: msg, usage: [10, 2] }; } return { message: { role: "assistant", content: s.content ?? null }, usage: [10, 2], }; }, // expose a way for tests to script chat replies } as ProviderService; (provider as unknown as { setChatReply: (s: string) => void }).setChatReply = (text: string) => { chatResponses.push(text); }; return { provider, seen, chatCalls }; } /** A ToolExecutor that echoes fixed outputs per tool. */ function fixedExecutor(outs: Record): ToolExecutor { return { async execute(name) { return outs[name] ?? "ok"; }, isParallelSafe() { return false; }, }; } function buildParams(userText: string): { params: AgentTurnParams; events: Array; } { const events: Array = []; const params: AgentTurnParams = { messages: [userMessage(userText)], session_dir: "/tmp/zesdex-flow-test", workspace_roots: ["/nonexistent-zesdex-dir"], // no AGENTS.md/package.json side effects turn_events: { push: (e) => events.push(e), drain: () => [] }, in_flight: { value: false }, abort: new AbortController(), api_key: "k", model: "m", api_base: "https://x", }; return { params, events }; } describe("runTurn", () => { it("runs a simple one-shot prompt to completion", async () => { const { provider } = makeScriptedProvider([{ content: "here is the answer" }]); const svc = new AgentTurnServiceImpl(provider as never, fixedExecutor({}), [] as never); const { params } = buildParams("hello"); await svc.runTurn(params); expect(params.in_flight.value).toBe(false); }); }); describe("runTurn verify-after-edit", () => { it("injects a verify nudge after a write, cleared after a successful bash", async () => { const { provider, seen } = makeScriptedProvider([ { tools: [{ name: "write" }] }, { tools: [{ name: "bash" }] }, { content: "done" }, ]); const executor = fixedExecutor({ write: "wrote it", bash: "ok\n\nExit code: 0 (1s)" }); const svc = new AgentTurnServiceImpl(provider as never, executor, [] as never); const { params } = buildParams("add a comment"); await svc.runTurn(params); // The LLM call after the write should carry the verify nudge. const second = seen[1] ?? []; expect(second.some((m) => (m.content ?? "").includes("Run the verify command"))).toBe(true); // The successful bash exits 0, which clears pendingVerify — so the nudge is // injected exactly once, not repeated on every later call. const finalBatch = seen[seen.length - 1] ?? []; const nudgeCount = finalBatch.filter((m) => (m.content ?? "").includes("Run the verify command")).length; expect(nudgeCount).toBe(1); }); }); describe("runTurn convergence guard", () => { it("stops after repeated identical read calls", async () => { const script: ScriptedStep[] = []; for (let i = 0; i < 6; i++) script.push({ tools: [{ name: "read" }] }); const { provider, seen } = makeScriptedProvider(script); const executor = fixedExecutor({ read: "same content" }); const svc = new AgentTurnServiceImpl(provider as never, executor, [] as never); const { params, events } = buildParams("read something"); await svc.runTurn(params); const warned = events.some((e) => e.kind === "system_note" && /without any progress|repeated/.test(e.message ?? "")); expect(warned).toBe(true); // Not every call got issued — the guard broke early. expect(seen.length).toBeLessThan(script.length); }); }); describe("runTurn self-review", () => { it("feeds a reviewer critique back as a system message after a mutator", async () => { const { provider, seen } = makeScriptedProvider([ { tools: [{ name: "edit" }] }, { content: "fixed" }, ]); const withChat = provider as unknown as { setChatReply(s: string): void }; withChat.setChatReply("- [PRIORITY: high] handle empty input in parse()"); const executor = fixedExecutor({ edit: "edited" }); const svc = new AgentTurnServiceImpl(provider as never, executor, [] as never); const { params, events } = buildParams("fix parse()"); await svc.runTurn(params); // The reviewer critique must have reached the next LLM call's history. const second = seen[1] ?? []; expect(second.some((m) => (m.content ?? "").includes("[Reviewer]"))).toBe(true); // review_usage emitted. expect(events.some((e) => e.kind === "review_usage")).toBe(true); }); it("passes the work-in-progress to the reviewer (not an empty slate)", async () => { const { provider, chatCalls } = makeScriptedProvider([ { tools: [{ name: "edit" }] }, { content: "fixed" }, ]); (provider as unknown as { setChatReply(s: string): void }).setChatReply( "- [PRIORITY: medium] add a null check", ); const svc = new AgentTurnServiceImpl(provider as never, fixedExecutor({ edit: "changed" }), [] as never); const { params } = buildParams("harden the parser"); await svc.runTurn(params); // The REVIEWER chat call carries the digest in its user message. const reviewerCall = chatCalls.find((c) => c.some((m) => (m.content ?? "").includes("Review the work-in-progress below")), ); expect(reviewerCall).toBeTruthy(); expect(reviewerCall!.some((m) => (m.content ?? "").includes("harden the parser"))).toBe(true); }); }); /* ── Compaction: efficient + accurate ─────────────────────────────── */ describe("takeRecentTail", () => { it("keeps the most recent messages and evicts an oversized older blob", () => { const messages = [ toolResultMessage("t0", "y".repeat(5000)), // huge old tool output userMessage("old request"), assistantMessage("recent reply A"), userMessage("recent reply B"), ]; const { tail, evicted } = takeRecentTail(messages, 25, 2); expect(evicted.length).toBe(2); // the blob + the old request expect(tail.map((m) => m.content)).toEqual(["recent reply A", "recent reply B"]); }); it("gives the whole history as tail when it is small enough", () => { const messages = [userMessage("a"), assistantMessage("b")]; const { tail, evicted } = takeRecentTail(messages, 1000, 6); expect(evicted).toEqual([]); expect(tail.length).toBe(2); }); it("does not let a huge tool output blow the tail past the min count", () => { // minTail 2: newest two kept regardless; the huge tool body stays evicted. const messages = [ toolResultMessage("t0", "x".repeat(50000)), userMessage("keep1"), assistantMessage("keep2"), ]; const { tail, evicted } = takeRecentTail(messages, 8000, 2); expect(tail.map((m) => m.content)).toEqual(["keep1", "keep2"]); expect(evicted.length).toBe(1); }); }); describe("reduceMessagesToDigest", () => { it("stubs tool bodies entirely and previews text", () => { const digest = reduceMessagesToDigest([ toolResultMessage("t0", "y".repeat(5000)), userMessage("short request"), ]); expect(digest).toContain("- tool"); expect(digest).not.toContain("yyyy"); expect(digest).toContain("short request"); }); it("keeps user previews well above the shorter assistant cap", () => { const long = "w".repeat(2000); // User budget is 1500 chars — larger than the 400-char assistant cap, so // a long user request survives far more of its body than a long assistant // reply would (requests matter most for summary accuracy). const digestUser = reduceMessagesToDigest([userMessage(long)]); const digestAssistant = reduceMessagesToDigest([assistantMessage(long)]); expect(digestUser.length).toBeGreaterThan(800); expect(digestAssistant.length).toBeLessThan(500); }); it("respects the hard input cap", () => { const many = []; for (let i = 0; i < 200; i++) many.push(userMessage("r".repeat(300))); const digest = reduceMessagesToDigest(many); expect(digest.length).toBeLessThanOrEqual(20_000 + 40); }); }); describe("compactMessagesWithAi", () => { it("keeps the recent tail and prepends an AI summary", async () => { // Long enough to bypass the min-size guard, with an oversized OLD tool blob // (should be evicted + stubbed, not kept verbatim). const messages: ChatMessage[] = [ userMessage("old request one"), assistantMessage("old response"), toolResultMessage("t0", "big".repeat(4000)), ]; for (let i = 0; i < 7; i++) messages.push(userMessage(`mid ${i}`)); messages.push(toolResultMessage("t1", "tail-result")); messages.push(userMessage("newest request")); messages.push(assistantMessage("newest response")); const provider: ProviderService = { async chat() { return { message: { role: "assistant", content: "## Requests\n- old request one" }, usage: null }; }, async chatStream() { return { message: { role: "assistant", content: null }, usage: null }; }, }; await compactMessagesWithAi(messages, provider); // Summary first, recent tail preserved verbatim, tool blobs gone. expect(messages[0]?.content).toContain("[AI Summary of Previous Conversation]"); expect(messages.some((m) => m.content === "newest request")).toBe(true); expect(messages.some((m) => m.content === "newest response")).toBe(true); expect(messages.some((m) => (m.content ?? "").includes("big".repeat(10)))).toBe(false); }); it("degrades gracefully on summarizer failure, preserving the tail", async () => { const messages = [ userMessage("old"), toolResultMessage("t0", "x".repeat(100)), userMessage("recent request"), ]; const failing: ProviderService = { async chat() { throw new Error("network down"); }, async chatStream() { return { message: { role: "assistant", content: null }, usage: null }; }, }; await compactMessagesWithAi(messages, failing); // The recent message survives even when the LLM call fails. expect(messages.some((m) => m.content === "recent request")).toBe(true); }); });