142 lines
4.6 KiB
TypeScript
142 lines
4.6 KiB
TypeScript
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
|
import { SessionManager } from "@mariozechner/pi-coding-agent";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { flushPendingToolResultsAfterIdle } from "./pi-embedded-runner/wait-for-idle-before-flush.js";
|
|
import { guardSessionManager } from "./session-tool-result-guard-wrapper.js";
|
|
|
|
function assistantToolCall(id: string): AgentMessage {
|
|
return {
|
|
role: "assistant",
|
|
content: [{ type: "toolCall", id, name: "exec", arguments: {} }],
|
|
stopReason: "toolUse",
|
|
} as AgentMessage;
|
|
}
|
|
|
|
function toolResult(id: string, text: string): AgentMessage {
|
|
return {
|
|
role: "toolResult",
|
|
toolCallId: id,
|
|
content: [{ type: "text", text }],
|
|
isError: false,
|
|
} as AgentMessage;
|
|
}
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
const promise = new Promise<T>((r) => {
|
|
resolve = r;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
function getMessages(sm: ReturnType<typeof guardSessionManager>): AgentMessage[] {
|
|
return sm
|
|
.getEntries()
|
|
.filter((e) => e.type === "message")
|
|
.map((e) => (e as { message: AgentMessage }).message);
|
|
}
|
|
|
|
describe("flushPendingToolResultsAfterIdle", () => {
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("waits for idle so real tool results can land before flush", async () => {
|
|
const sm = guardSessionManager(SessionManager.inMemory());
|
|
const appendMessage = sm.appendMessage.bind(sm) as unknown as (message: AgentMessage) => void;
|
|
const idle = deferred<void>();
|
|
const agent = { waitForIdle: () => idle.promise };
|
|
|
|
appendMessage(assistantToolCall("call_retry_1"));
|
|
const flushPromise = flushPendingToolResultsAfterIdle({
|
|
agent,
|
|
sessionManager: sm,
|
|
timeoutMs: 1_000,
|
|
});
|
|
|
|
// Flush is waiting for idle; synthetic result must not appear yet.
|
|
await Promise.resolve();
|
|
expect(getMessages(sm).map((m) => m.role)).toEqual(["assistant"]);
|
|
|
|
// Tool completes before idle wait finishes.
|
|
appendMessage(toolResult("call_retry_1", "command output here"));
|
|
idle.resolve();
|
|
await flushPromise;
|
|
|
|
const messages = getMessages(sm);
|
|
expect(messages.map((m) => m.role)).toEqual(["assistant", "toolResult"]);
|
|
expect((messages[1] as { isError?: boolean }).isError).not.toBe(true);
|
|
expect((messages[1] as { content?: Array<{ text?: string }> }).content?.[0]?.text).toBe(
|
|
"command output here",
|
|
);
|
|
});
|
|
|
|
it("flushes pending tool call after timeout when idle never resolves", async () => {
|
|
const sm = guardSessionManager(SessionManager.inMemory());
|
|
const appendMessage = sm.appendMessage.bind(sm) as unknown as (message: AgentMessage) => void;
|
|
vi.useFakeTimers();
|
|
const agent = { waitForIdle: () => new Promise<void>(() => {}) };
|
|
|
|
appendMessage(assistantToolCall("call_orphan_1"));
|
|
|
|
const flushPromise = flushPendingToolResultsAfterIdle({
|
|
agent,
|
|
sessionManager: sm,
|
|
timeoutMs: 30,
|
|
});
|
|
await vi.advanceTimersByTimeAsync(30);
|
|
await flushPromise;
|
|
|
|
const entries = getMessages(sm);
|
|
|
|
expect(entries.length).toBe(2);
|
|
expect(entries[1].role).toBe("toolResult");
|
|
expect((entries[1] as { isError?: boolean }).isError).toBe(true);
|
|
expect((entries[1] as { content?: Array<{ text?: string }> }).content?.[0]?.text).toContain(
|
|
"missing tool result",
|
|
);
|
|
});
|
|
|
|
it("clears pending without synthetic flush when timeout cleanup is requested", async () => {
|
|
const sm = guardSessionManager(SessionManager.inMemory());
|
|
const appendMessage = sm.appendMessage.bind(sm) as unknown as (message: AgentMessage) => void;
|
|
vi.useFakeTimers();
|
|
const agent = { waitForIdle: () => new Promise<void>(() => {}) };
|
|
|
|
appendMessage(assistantToolCall("call_orphan_2"));
|
|
|
|
const flushPromise = flushPendingToolResultsAfterIdle({
|
|
agent,
|
|
sessionManager: sm,
|
|
timeoutMs: 30,
|
|
clearPendingOnTimeout: true,
|
|
});
|
|
await vi.advanceTimersByTimeAsync(30);
|
|
await flushPromise;
|
|
|
|
expect(getMessages(sm).map((m) => m.role)).toEqual(["assistant"]);
|
|
|
|
appendMessage({
|
|
role: "user",
|
|
content: "still there?",
|
|
timestamp: Date.now(),
|
|
} as AgentMessage);
|
|
expect(getMessages(sm).map((m) => m.role)).toEqual(["assistant", "user"]);
|
|
});
|
|
|
|
it("clears timeout handle when waitForIdle resolves first", async () => {
|
|
const sm = guardSessionManager(SessionManager.inMemory());
|
|
vi.useFakeTimers();
|
|
const agent = {
|
|
waitForIdle: async () => {},
|
|
};
|
|
|
|
await flushPendingToolResultsAfterIdle({
|
|
agent,
|
|
sessionManager: sm,
|
|
timeoutMs: 30_000,
|
|
});
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
});
|
|
});
|