The todos pre-scan walked .output/ (Nitro) and .vercel/ (Vercel) build dirs, flagging 119 of 124 candidates inside minified bundles (single lines up to 162KB). buildTodosScanTask embedded full candidate lines in the task prompt, producing a 2.5MB prompt on freno-dev; the analysis agent settled with ok:true + empty text + no findings.md, verify failed, and resume re-ran the same oversized prompt and failed identically. - scope: exclude .output/.vercel/.netlify (shared by all checks) - todos: truncate candidate code at 160 chars in the prompt + fallback - agent-runner: a session settling with no text and no observed message/ tool events now fails the run loudly instead of reporting ok:true - agent prompts: add the three dirs to each skip list - tests: excluded-dir scan, prompt truncation, emptySessionError cases
189 lines
5.5 KiB
TypeScript
189 lines
5.5 KiB
TypeScript
/**
|
|
* agent-runner.test.ts — unit tests for the session event accumulator.
|
|
*
|
|
* `applySessionEvent` runs synchronously inside the SDK's event pipeline; a
|
|
* throw there crashes the host (the SDK's run-failure path re-emits events
|
|
* through the same callback → stack overflow), stranding run-state at the
|
|
* phase boundary. These tests pin the handler to never throw on the partial /
|
|
* malformed event shapes streaming sessions actually emit.
|
|
*/
|
|
import { describe, expect, it } from "bun:test";
|
|
import {
|
|
applySessionEvent,
|
|
emptySessionError,
|
|
type SessionEventAccumulator,
|
|
} from "../src/agent-runner.js";
|
|
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
|
|
function fresh(): SessionEventAccumulator {
|
|
return { text: "", sawMessage: false };
|
|
}
|
|
|
|
/** Build a typed-as-unknown event so malformed shapes compile in tests. */
|
|
function event(shape: unknown): AgentSessionEvent {
|
|
return shape as AgentSessionEvent;
|
|
}
|
|
|
|
describe("applySessionEvent", () => {
|
|
it("accumulates text_delta stream events in order", () => {
|
|
const acc = fresh();
|
|
applySessionEvent(
|
|
acc,
|
|
event({
|
|
type: "message_update",
|
|
assistantMessageEvent: { type: "text_delta", delta: "foo" },
|
|
}),
|
|
);
|
|
applySessionEvent(
|
|
acc,
|
|
event({
|
|
type: "message_update",
|
|
assistantMessageEvent: { type: "text_delta", delta: "bar" },
|
|
}),
|
|
);
|
|
expect(acc.text).toBe("foobar");
|
|
});
|
|
|
|
it("does not throw on a message_update with no assistantMessageEvent", () => {
|
|
const acc = fresh();
|
|
expect(() =>
|
|
applySessionEvent(acc, event({ type: "message_update" })),
|
|
).not.toThrow();
|
|
expect(acc.text).toBe("");
|
|
});
|
|
|
|
it("does not throw on a message_update with an unknown event shape", () => {
|
|
const acc = fresh();
|
|
expect(() => applySessionEvent(acc, event({ type: "bogus_event" }))).not.toThrow();
|
|
expect(() => applySessionEvent(acc, event(null))).not.toThrow();
|
|
expect(() => applySessionEvent(acc, event(undefined))).not.toThrow();
|
|
expect(acc.text).toBe("");
|
|
});
|
|
|
|
it("captures full text + stopReason from message_end when nothing streamed", () => {
|
|
const acc = fresh();
|
|
applySessionEvent(
|
|
acc,
|
|
event({
|
|
type: "message_end",
|
|
message: {
|
|
role: "assistant",
|
|
stopReason: "stop",
|
|
content: [{ type: "text", text: "full report" }],
|
|
},
|
|
}),
|
|
);
|
|
expect(acc.text).toBe("full report");
|
|
expect(acc.stopReason).toBe("stop");
|
|
});
|
|
|
|
it("does not throw on a message_end with no message", () => {
|
|
const acc = fresh();
|
|
expect(() => applySessionEvent(acc, event({ type: "message_end" }))).not.toThrow();
|
|
expect(acc.text).toBe("");
|
|
});
|
|
|
|
it("records an errorMessage surfaced on the final message", () => {
|
|
const acc = fresh();
|
|
applySessionEvent(
|
|
acc,
|
|
event({
|
|
type: "message_end",
|
|
message: { role: "assistant", errorMessage: "upstream 529" },
|
|
}),
|
|
);
|
|
expect(acc.errorMessage).toBe("upstream 529");
|
|
});
|
|
|
|
it("forwards stream-driving events and swallows a throwing forwarder", () => {
|
|
const originalError = console.error;
|
|
console.error = () => {};
|
|
try {
|
|
const forwarded: string[] = [];
|
|
const forward = (ev: AgentSessionEvent) => {
|
|
forwarded.push(ev.type);
|
|
if (ev.type === "tool_execution_end") throw new Error("renderer boom");
|
|
};
|
|
const acc = fresh();
|
|
applySessionEvent(
|
|
acc,
|
|
event({ type: "tool_execution_start", toolName: "bash" }),
|
|
forward,
|
|
);
|
|
applySessionEvent(
|
|
acc,
|
|
event({ type: "tool_execution_end", toolName: "bash" }),
|
|
forward,
|
|
);
|
|
expect(forwarded).toEqual(["tool_execution_start", "tool_execution_end"]);
|
|
} finally {
|
|
console.error = originalError;
|
|
}
|
|
});
|
|
|
|
it("does not forward non-stream-driving events", () => {
|
|
const forwarded: string[] = [];
|
|
const acc = fresh();
|
|
applySessionEvent(
|
|
acc,
|
|
event({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "x" } }),
|
|
(ev) => forwarded.push(ev.type),
|
|
);
|
|
applySessionEvent(acc, event({ type: "agent_settled" }), (ev) =>
|
|
forwarded.push(ev.type),
|
|
);
|
|
expect(forwarded).toEqual([]);
|
|
expect(acc.text).toBe("x");
|
|
});
|
|
|
|
it("marks sawMessage when any message or tool event is observed", () => {
|
|
const fromUpdate = fresh();
|
|
applySessionEvent(fromUpdate, event({ type: "message_update" }));
|
|
expect(fromUpdate.sawMessage).toBe(true);
|
|
|
|
const fromEnd = fresh();
|
|
applySessionEvent(fromEnd, event({ type: "message_end", message: null }));
|
|
expect(fromEnd.sawMessage).toBe(true);
|
|
|
|
const fromTool = fresh();
|
|
applySessionEvent(fromTool, event({ type: "tool_execution_start" }));
|
|
expect(fromTool.sawMessage).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("emptySessionError", () => {
|
|
it("fails a session that settled with no text and no observed events", () => {
|
|
expect(emptySessionError({ text: "", sawMessage: false })).toContain(
|
|
"no output",
|
|
);
|
|
});
|
|
|
|
it("fails an empty session whose final message reported stopReason error", () => {
|
|
expect(
|
|
emptySessionError({ text: "", sawMessage: true, stopReason: "error" }),
|
|
).toBe("sub-agent session ended in error with no output.");
|
|
});
|
|
|
|
it("surfaces a recorded errorMessage regardless of text", () => {
|
|
expect(
|
|
emptySessionError({
|
|
text: "partial output",
|
|
sawMessage: true,
|
|
errorMessage: "upstream 529",
|
|
}),
|
|
).toBe("upstream 529");
|
|
});
|
|
|
|
it("accepts an empty-text session that demonstrably ran (tool activity)", () => {
|
|
expect(
|
|
emptySessionError({ text: "", sawMessage: true, stopReason: "end_turn" }),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("accepts any session with text", () => {
|
|
expect(
|
|
emptySessionError({ text: "report", sawMessage: false }),
|
|
).toBeUndefined();
|
|
});
|
|
});
|