237 lines
7.2 KiB
TypeScript
237 lines
7.2 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("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);
|
|
});
|
|
});
|