387 lines
15 KiB
TypeScript
387 lines
15 KiB
TypeScript
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<ChatMessage[]>;
|
|
chatCalls: Array<ChatMessage[]>;
|
|
} {
|
|
const seen: Array<ChatMessage[]> = [];
|
|
const chatCalls: Array<ChatMessage[]> = [];
|
|
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<string, string>): ToolExecutor {
|
|
return {
|
|
async execute(name) {
|
|
return outs[name] ?? "ok";
|
|
},
|
|
isParallelSafe() {
|
|
return false;
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildParams(userText: string): {
|
|
params: AgentTurnParams;
|
|
events: Array<any>;
|
|
} {
|
|
const events: Array<any> = [];
|
|
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);
|
|
});
|
|
});
|