Files
Christian Stewart e79eabc3b6 feat(agent): add a pre-model-call gate that can stop the turn
The agent loop had no place to refuse a provider request. A host that needs to
act on the assembled context before it is billed, checking that the prompt still
fits the window, that a budget boundary has not been crossed, or that the
session should hand off instead of spending, could only observe the request
after the fact, when the tokens were already committed.

Add `AgentLoopConfig.beforeModelCall`, asked once per turn beside the deadline
check and before `turn_start` is emitted. A `stop` result ends the stream with
no turn open, so nothing has to synthesise a cancellation event and no consumer
is left holding a half-open turn. Placing it there also keeps `turn_end`'s
contract intact: that event carries the assistant message for a completed turn,
and a gated stop has no assistant message to report.

`syncContextBeforeModelCall` keeps its existing void contract and its job of
refreshing prompt and tool state, so implementations typed as returning void are
unaffected.

`Agent.setBeforeModelCall` installs the host's callback, and `addBeforeModelCall`
registers an additional callback without displacing the host's, returning a
disposer so an extension can attach and detach independently. A supplied
`reason` is logged where the loop stops.

Signed-off-by: Christian Stewart <christian@aperture.us>
2026-07-26 12:02:18 -07:00

265 lines
8.1 KiB
TypeScript

import { describe, expect, it } from "bun:test";
import type { RejectInfo, ResolveInfo } from "@oh-my-pi/pi-coding-agent/session/tool-choice-queue";
import { ToolChoiceQueue } from "@oh-my-pi/pi-coding-agent/session/tool-choice-queue";
const forced = { type: "tool", name: "write" } as const;
const forcedRead = { type: "tool", name: "read" } as const;
describe("ToolChoiceQueue", () => {
describe("resolve callback", () => {
it("fires onResolved with the served choice", () => {
const q = new ToolChoiceQueue();
const resolved: ResolveInfo[] = [];
q.pushOnce(forced, {
label: "a",
onResolved: info => resolved.push(info),
});
q.nextToolChoice();
q.resolve();
expect(resolved).toEqual([{ choice: forced }]);
});
});
describe("reject callback", () => {
it("onRejected returning 'requeue' replays the lost yield", () => {
const q = new ToolChoiceQueue();
const rejected: RejectInfo[] = [];
q.pushSequence([forced, "none"], {
label: "user-force",
onRejected: info => {
rejected.push(info);
return "requeue";
},
});
expect(q.nextToolChoice()).toEqual(forced);
q.reject("aborted");
// Callback received the right info
expect(rejected).toEqual([{ choice: forced, reason: "aborted" }]);
// Next turn: replayed yield, then original sequence continues
expect(q.nextToolChoice()).toEqual(forced);
q.resolve();
expect(q.nextToolChoice()).toBe("none");
q.resolve();
expect(q.nextToolChoice()).toBeUndefined();
});
it("onRejected returning 'drop' discards the yield", () => {
const q = new ToolChoiceQueue();
q.pushOnce(forced, {
label: "eager-todo",
onRejected: () => "drop",
});
expect(q.nextToolChoice()).toEqual(forced);
q.reject("aborted");
expect(q.nextToolChoice()).toBeUndefined();
});
it("onRejected returning 'drop_sequence' discards its later yields", () => {
const q = new ToolChoiceQueue();
q.pushSequence([forced, "none"], {
label: "user-force",
onRejected: () => "drop_sequence",
});
q.pushOnce(forcedRead, { label: "later" });
expect(q.nextToolChoice()).toEqual(forced);
q.reject("unavailable");
expect(q.nextToolChoice()).toEqual(forcedRead);
});
it("drop_sequence removes the origin after an unavailable replay", () => {
const q = new ToolChoiceQueue();
q.pushSequence([forced, "none"], {
label: "user-force",
onRejected: info => (info.reason === "unavailable" ? "drop_sequence" : "requeue"),
});
q.pushOnce(forcedRead, { label: "later" });
expect(q.nextToolChoice()).toEqual(forced);
q.reject("aborted");
expect(q.nextToolChoice()).toEqual(forced);
q.reject("unavailable");
expect(q.nextToolChoice()).toEqual(forcedRead);
});
it("requeued directive preserves onRejected so it can re-requeue across aborts", () => {
const q = new ToolChoiceQueue();
let rejectCount = 0;
q.pushOnce(forced, {
label: "user-force",
onRejected: () => {
rejectCount++;
return rejectCount < 3 ? "requeue" : "drop";
},
});
// First abort → requeue (count 1)
q.nextToolChoice();
q.reject("aborted");
expect(rejectCount).toBe(1);
// Second abort → requeue again via preserved callback (count 2)
q.nextToolChoice();
q.reject("aborted");
expect(rejectCount).toBe(2);
// Third abort → callback returns "drop" (count 3), queue drained
q.nextToolChoice();
q.reject("aborted");
expect(rejectCount).toBe(3);
expect(q.nextToolChoice()).toBeUndefined();
});
});
describe("removeByLabel", () => {
it("removes targeted directives without affecting others", () => {
const q = new ToolChoiceQueue();
q.pushOnce(forced, { label: "eager-todo" });
q.pushOnce(forcedRead, { label: "user-force" });
q.removeByLabel("eager-todo");
expect(q.inspect()).toEqual(["user-force"]);
expect(q.nextToolChoice()).toEqual(forcedRead);
});
it("rejects in-flight if its label matches", () => {
const q = new ToolChoiceQueue();
const rejected: RejectInfo[] = [];
q.pushOnce(forced, {
label: "eager-todo",
onRejected: info => {
rejected.push(info);
return "drop";
},
});
q.nextToolChoice();
q.removeByLabel("eager-todo");
expect(rejected).toEqual([{ choice: forced, reason: "removed" }]);
expect(q.hasInFlight).toBe(false);
});
});
describe("clear", () => {
it("empties queue and rejects in-flight", () => {
const q = new ToolChoiceQueue();
const rejected: RejectInfo[] = [];
q.pushSequence([forced, "none"], {
label: "seq",
onRejected: info => {
rejected.push(info);
return "requeue"; // should still be dropped by clear
},
});
q.nextToolChoice();
q.clear();
// onRejected fired with "cleared" reason
expect(rejected).toEqual([{ choice: forced, reason: "cleared" }]);
// Even though onRejected returned "requeue", clear empties everything
expect(q.nextToolChoice()).toBeUndefined();
expect(q.inspect()).toEqual([]);
});
});
describe("consumeLastServedLabel", () => {
it("returns label once then clears", () => {
const q = new ToolChoiceQueue();
q.pushOnce(forced, { label: "user-force" });
q.nextToolChoice();
q.resolve();
expect(q.consumeLastServedLabel()).toBe("user-force");
expect(q.consumeLastServedLabel()).toBeUndefined();
});
});
describe("hasInFlight", () => {
it("is false when queue is empty", () => {
const q = new ToolChoiceQueue();
expect(q.hasInFlight).toBe(false);
});
it("is true after nextToolChoice, false after resolve", () => {
const q = new ToolChoiceQueue();
q.pushOnce(forced, { label: "a" });
q.nextToolChoice();
expect(q.hasInFlight).toBe(true);
q.resolve();
expect(q.hasInFlight).toBe(false);
});
});
});
describe("onInvoked / peekInFlightInvoker", () => {
it("exposes the in-flight directive's onInvoked handler via peekInFlightInvoker", async () => {
const q = new ToolChoiceQueue();
q.pushOnce(forced, {
label: "pending",
onInvoked: async input => ({ echoed: input }),
});
q.nextToolChoice();
const invoker = q.peekInFlightInvoker();
expect(invoker).toBeDefined();
const result = await invoker!({ action: "apply", reason: "ok" });
expect(result).toEqual({ echoed: { action: "apply", reason: "ok" } });
});
it("does not resolve an onInvoked directive until the requested tool runs", () => {
const q = new ToolChoiceQueue();
const rejected: RejectInfo[] = [];
const resolved: ResolveInfo[] = [];
q.pushOnce(forced, {
label: "pending",
onRejected: info => {
rejected.push(info);
return "requeue";
},
onResolved: info => resolved.push(info),
onInvoked: async input => ({ echoed: input }),
});
q.nextToolChoice();
q.resolve();
expect(rejected).toEqual([{ choice: forced, reason: "not_invoked" }]);
expect(resolved).toEqual([]);
expect(q.nextToolChoice()).toEqual(forced);
});
it("resolves an onInvoked directive after the requested tool runs", async () => {
const q = new ToolChoiceQueue();
const resolved: ResolveInfo[] = [];
q.pushOnce(forced, {
label: "pending",
onResolved: info => resolved.push(info),
onInvoked: async input => ({ echoed: input }),
});
q.nextToolChoice();
const invoker = q.peekInFlightInvoker();
expect(invoker).toBeDefined();
await invoker!({ action: "apply", reason: "ok" });
q.resolve();
expect(resolved).toEqual([{ choice: forced }]);
expect(q.hasInFlight).toBe(false);
});
it("returns undefined when no directive is in-flight", () => {
const q = new ToolChoiceQueue();
expect(q.peekInFlightInvoker()).toBeUndefined();
});
it("carries onInvoked across requeue so replayed directive still handles invocations", async () => {
const q = new ToolChoiceQueue();
let invocationCount = 0;
q.pushOnce(forced, {
label: "pending",
onRejected: () => "requeue",
onInvoked: async () => {
invocationCount++;
return "handled";
},
});
// First turn: aborted, requeued
q.nextToolChoice();
q.reject("aborted");
// Next turn: invoker is still reachable via peekInFlightInvoker
q.nextToolChoice();
const invoker = q.peekInFlightInvoker();
expect(invoker).toBeDefined();
const result = await invoker!({});
expect(result).toBe("handled");
expect(invocationCount).toBe(1);
});
});