feat(tui): add new flow helpers for bash exit code handling and verification prompts
This commit is contained in:
@@ -4,8 +4,22 @@ import {
|
||||
conversationChars,
|
||||
ErrorTracker,
|
||||
truncateToolOutput,
|
||||
bashExitCode,
|
||||
isToolFailure,
|
||||
deriveVerifyCommand,
|
||||
AgentTurnServiceImpl,
|
||||
} from "./turn_service.ts";
|
||||
import { type ChatMessage, newConversation, systemMessage, userMessage, toolResultMessage } from "@zesdex/domain";
|
||||
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", () => {
|
||||
@@ -56,4 +70,197 @@ describe("conversationChars", () => {
|
||||
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[]>;
|
||||
} {
|
||||
const seen: 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.
|
||||
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 };
|
||||
}
|
||||
|
||||
/** 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 };
|
||||
}
|
||||
|
||||
/** Extract the `[Flow]` steering system message from a captured batch. */
|
||||
function flowDirective(batch: ChatMessage[]): string {
|
||||
const m = batch.find((x) => (x.content ?? "").includes("[Flow]"));
|
||||
return m?.content ?? "";
|
||||
}
|
||||
|
||||
describe("runTurn complexity steering", () => {
|
||||
it("injects a complex-request directive for a complex prompt", async () => {
|
||||
const { provider, seen } = makeScriptedProvider([
|
||||
{ content: "final answer" },
|
||||
]);
|
||||
const svc = new AgentTurnServiceImpl(provider as never, fixedExecutor({}), [] as never);
|
||||
const { params } = buildParams("Please refactor the architecture across multiple files");
|
||||
await svc.runTurn(params);
|
||||
// The complexity directive system message must be present before the LLM.
|
||||
const flow = flowDirective(seen[0] ?? []);
|
||||
expect(flow).toContain("flagged as complex");
|
||||
});
|
||||
|
||||
it("injects a keep-it-simple directive instead", async () => {
|
||||
const { provider, seen } = makeScriptedProvider([{ content: "hi" }]);
|
||||
const svc = new AgentTurnServiceImpl(provider as never, fixedExecutor({}), [] as never);
|
||||
const { params } = buildParams("what is 2+2");
|
||||
await svc.runTurn(params);
|
||||
const flow = flowDirective(seen[0] ?? []);
|
||||
expect(flow).toContain("looks simple");
|
||||
expect(flow).not.toContain("complex");
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user