Files
oh-my-pi/packages/coding-agent/test/agent-session-queued-steer-delivery.test.ts
T
can1357 42ffc83b5d fix: re-polled steering after yield and drained queued follow-ups after turns
- Re-polled steering at the loop yield boundary and included it in the pre-stop pending batch so late messages are processed immediately.
- Added session-side draining for stranded queued messages, scheduling an auto-continue when a prompt settles and follow-ups or steers remain.
- Added a regression test for late steering injection at yield and updated mid-turn collab prompt handling to keep steering messages in the pending display queue until consumed.
2026-06-12 17:02:42 +02:00

160 lines
5.8 KiB
TypeScript

/**
* Contract: a custom message steered into a streaming session (the collab-host
* and skill-prompt path: `promptCustomMessage(..., { streamingBehavior: "steer" })`)
* is always delivered — never silently stranded in the agent's steering queue.
*
* Two regression seams, both observed as "guest messages just disappear" in
* collab sessions:
* 1. A steer landing at the run's yield boundary (after the stop-boundary
* dequeue) must force another turn instead of stranding.
* 2. A steer landing while the prompt unwinds (isStreaming stays true through
* post-prompt recovery, but the loop is already done) must be drained when
* the session settles.
*/
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { Agent } from "@oh-my-pi/pi-agent-core";
import { createMockModel, type MockModel } from "@oh-my-pi/pi-ai/providers/mock";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { Snowflake } from "@oh-my-pi/pi-utils";
const COLLAB_PROMPT_TYPE = "collab-prompt";
interface SteerHarness {
session: AgentSession;
sessionManager: SessionManager;
mock: MockModel;
}
describe("AgentSession queued steer delivery", () => {
let tempDir: string;
let session: AgentSession;
const authStorages: AuthStorage[] = [];
beforeEach(() => {
tempDir = path.join(os.tmpdir(), `pi-steer-strand-${Snowflake.next()}`);
fs.mkdirSync(tempDir, { recursive: true });
});
afterEach(async () => {
await session?.dispose();
for (const authStorage of authStorages.splice(0)) {
authStorage.close();
}
fs.rmSync(tempDir, { recursive: true, force: true });
});
async function createSession(responses: { content: string[] }[]): Promise<SteerHarness> {
const model = getBundledModel("anthropic", "claude-sonnet-4-5")!;
const mock = createMockModel({ responses });
const agent = new Agent({
getApiKey: () => "test-key",
initialState: { model, systemPrompt: ["Test"], tools: [] },
streamFn: mock.stream,
});
const sessionManager = SessionManager.inMemory();
const settings = Settings.isolated({ "compaction.enabled": false });
const authStorage = await AuthStorage.create(path.join(tempDir, `auth-${Snowflake.next()}.db`));
authStorages.push(authStorage);
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml"));
session = new AgentSession({ agent, sessionManager, settings, modelRegistry });
return { session, sessionManager, mock };
}
function steerCollabPrompt(target: AgentSession, text: string): Promise<void> {
return target.promptCustomMessage(
{
customType: COLLAB_PROMPT_TYPE,
content: text,
display: true,
details: { from: "guest" },
attribution: "user",
},
{ streamingBehavior: "steer" },
);
}
/** Resolves with the entry text when a collab-prompt entry is persisted. */
function nextCollabEntry(sessionManager: SessionManager): Promise<string> {
const { promise, resolve } = Promise.withResolvers<string>();
sessionManager.onEntryAppended = entry => {
if (entry.type === "custom_message" && entry.customType === COLLAB_PROMPT_TYPE) {
resolve(typeof entry.content === "string" ? entry.content : JSON.stringify(entry.content));
}
};
return promise;
}
it("delivers a collab steer that lands at the run's yield boundary", async () => {
const { session, sessionManager, mock } = await createSession([
{ content: ["host answer"] },
{ content: ["ack guest"] },
]);
const entryAppended = nextCollabEntry(sessionManager);
let streamingAtInject: boolean | undefined;
let injected = false;
session.agent.setOnBeforeYield(async () => {
if (injected) return;
injected = true;
// The session is still mid-prompt here, so this takes the steer path.
streamingAtInject = session.isStreaming;
await steerCollabPrompt(session, "guest steer at yield");
});
await session.prompt("hello");
expect(streamingAtInject).toBe(true);
expect(await entryAppended).toBe("guest steer at yield");
expect(mock.calls.length).toBe(2);
expect(session.agent.hasQueuedMessages()).toBe(false);
});
it("drains a steer stranded in the agent queue when the session settles", async () => {
const { session, sessionManager, mock } = await createSession([
{ content: ["host answer"] },
{ content: ["ack guest"] },
]);
const entryAppended = nextCollabEntry(sessionManager);
// Inject from the wire agent_end subscriber: it fires synchronously while
// the session settles (#promptInFlightCount just hit 0), after the agent
// loop's final queue poll — a message queued here is invisible to the run
// and must be picked up by the settle-time drain.
const secondRunDone = Promise.withResolvers<void>();
let agentEnds = 0;
session.subscribe(event => {
if (event.type !== "agent_end") return;
agentEnds++;
if (agentEnds === 1) {
session.agent.steer({
role: "custom",
customType: COLLAB_PROMPT_TYPE,
content: "guest steer at settle",
display: true,
details: { from: "guest" },
attribution: "user",
timestamp: Date.now(),
});
} else if (agentEnds === 2) {
secondRunDone.resolve();
}
});
await session.prompt("hello");
expect(await entryAppended).toBe("guest steer at settle");
await secondRunDone.promise;
expect(mock.calls.length).toBe(2);
expect(session.agent.hasQueuedMessages()).toBe(false);
});
});