Add active-turn steering (#3394)
* feat(agents): add active-turn steering Let users choose whether an immediate prompt interrupts or joins the active turn. The daemon owns exact-turn admission, provider delivery, safe fallback, and canonical timeline identity while queueing remains client-side. * fix(agents): stabilize steering history and replay QA * fix(agents): preserve steering timeline order * fix(agents): reconcile resumed provider history * fix(agents): serialize steering with cancellation * test(agents): prove Claude multi-tool steering * test(agents): force explicit history hydration * fix(agents): flush output before steer admission
This commit is contained in:
@@ -30,13 +30,14 @@ import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES } from "@/screens/workspace/workspace-deck-retention";
|
||||
import { delayBrowserAgentCreatedStatus } from "../support/helpers/new-workspace";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { selectModel } from "../support/helpers/app";
|
||||
import { gotoAppShell, openSettings, selectModel } from "../support/helpers/app";
|
||||
import { observeTimelineSubscriptions } from "../support/helpers/timeline-delivery";
|
||||
import {
|
||||
expectResumeOverflowFallsBackToOneTail,
|
||||
rememberTimelineRequestCounts,
|
||||
} from "../support/helpers/timeline-resume";
|
||||
import { workspaceDeckEntryLocator } from "../support/helpers/workspace-ui";
|
||||
import { expectInFlightForkAvailable } from "../support/helpers/assistant-fork";
|
||||
import {
|
||||
scrollTimelineToNewestLoadedEdge,
|
||||
scrollTimelineUntilOlderHistoryIsReachable,
|
||||
@@ -264,14 +265,20 @@ async function submitMessageThatWillBeRejected(page: Page, prompt: string): Prom
|
||||
|
||||
async function expectRejectedSubmissionRestored(
|
||||
page: Page,
|
||||
input: { prompt: string; errorMessage: string },
|
||||
input: { prompt: string; errorMessage: string; preservesActiveTurn?: boolean },
|
||||
): Promise<void> {
|
||||
await expect(page.getByText(input.errorMessage)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByRole("alert").filter({ hasText: input.errorMessage })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectComposerDraft(page, input.prompt);
|
||||
await expectComposerEditable(page);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
await expect(page.getByRole("button", { name: "Send message" })).toBeEnabled();
|
||||
await expect(page.getByTestId("user-message").filter({ hasText: input.prompt })).toHaveCount(0);
|
||||
if (input.preservesActiveTurn) {
|
||||
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
|
||||
return;
|
||||
}
|
||||
await expect(page.getByRole("button", { name: "Send message" })).toBeEnabled();
|
||||
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
|
||||
}
|
||||
|
||||
@@ -284,6 +291,60 @@ async function retryRestoredSubmission(page: Page, prompt: string): Promise<void
|
||||
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function configureSteerInSettings(page: Page): Promise<void> {
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+Comma`);
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
await selectSteerInSettings(page);
|
||||
}
|
||||
|
||||
async function selectSteerInSettings(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Steer", exact: true }).click();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const raw = await page.evaluate(() => localStorage.getItem("@paseo:app-settings"));
|
||||
return raw ? (JSON.parse(raw) as { sendBehavior?: unknown }).sendBehavior : null;
|
||||
})
|
||||
.toBe("steer");
|
||||
}
|
||||
|
||||
async function replaySteeredSleepTurnInBrowser(
|
||||
page: Page,
|
||||
testInfo: { workerIndex: number },
|
||||
shape: "claude" | "codex",
|
||||
): Promise<void> {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
gate.holdNextShellToolCall("completed");
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await selectSteerInSettings(page);
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
prefix: `steer-replay-${shape}-${testInfo.workerIndex}-`,
|
||||
model: "ten-second-stream",
|
||||
prompt: `Replay a ${shape}-shaped foreground shell tool call while the user steers this turn.`,
|
||||
});
|
||||
try {
|
||||
await expect(page.getByTestId("tool-call-badge").last()).toBeVisible({ timeout: 30_000 });
|
||||
await expectComposerVisible(page);
|
||||
await submitMessage(page, "hello");
|
||||
|
||||
await expect(page.getByText("hello", { exact: true })).toHaveCount(1);
|
||||
await expect(page.getByRole("button", { name: /^Worked for/ })).toHaveCount(0);
|
||||
await expectInFlightForkAvailable(page);
|
||||
|
||||
await gate.waitForHeldServerMessage();
|
||||
gate.releaseHeldServerMessage();
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
|
||||
await expect(page.getByText("hello", { exact: true })).toHaveCount(1);
|
||||
await expect(page.getByRole("button", { name: /^Worked for/ })).toHaveCount(1);
|
||||
await expect(page.getByRole("button", { name: "Fork chat" }).last()).toBeVisible();
|
||||
} finally {
|
||||
gate.restore();
|
||||
await agent.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function queueMessage(page: Page, prompt: string): Promise<void> {
|
||||
await fillComposerDraft(page, prompt);
|
||||
await sendDraftToQueue(page);
|
||||
@@ -1030,6 +1091,83 @@ test.describe("Agent message submission", () => {
|
||||
await retryRestoredSubmission(page, prompt);
|
||||
});
|
||||
|
||||
test("restores an ambiguous Steer failure without retrying or interrupting", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const prompt = "Restore this ambiguous Steer submission.";
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
prefix: `steer-ambiguous-${testInfo.workerIndex}-`,
|
||||
model: "one-minute-stream",
|
||||
prompt: "Keep this turn active while a Steer request fails.",
|
||||
featureValues: { mockSteerAmbiguousFailures: 1 },
|
||||
});
|
||||
try {
|
||||
await configureSteerInSettings(page);
|
||||
await page.goBack();
|
||||
await expectComposerVisible(page);
|
||||
await expectAgentReadyToInterrupt(page);
|
||||
const sendsBefore = gate.getClientRequestCount("send_agent_message_request");
|
||||
const cancelsBefore = gate.getClientRequestCount("cancel_agent_request");
|
||||
|
||||
await submitMessageThatWillBeRejected(page, prompt);
|
||||
await expectRejectedSubmissionRestored(page, {
|
||||
prompt,
|
||||
errorMessage: "Requested mock steer transport failure",
|
||||
preservesActiveTurn: true,
|
||||
});
|
||||
|
||||
expect(gate.getClientRequestCount("send_agent_message_request")).toBe(sendsBefore + 1);
|
||||
expect(gate.getClientRequestCount("cancel_agent_request")).toBe(cancelsBefore);
|
||||
expect(gate.getClientRequests("send_agent_message_request").at(-1)).toMatchObject({
|
||||
text: prompt,
|
||||
activeTurnBehavior: "steer",
|
||||
});
|
||||
} finally {
|
||||
gate.restore();
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps an optimistic Steer prompt inside the active turn before acknowledgement", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
prefix: `steer-optimistic-${testInfo.workerIndex}-`,
|
||||
model: "one-minute-stream",
|
||||
prompt: "Keep this turn active while the user steers it.",
|
||||
});
|
||||
try {
|
||||
await configureSteerInSettings(page);
|
||||
await page.goBack();
|
||||
await expectComposerVisible(page);
|
||||
await expectAgentReadyToInterrupt(page);
|
||||
gate.holdNextServerMessage("send_agent_message_response");
|
||||
await submitMessage(page, "hello");
|
||||
await gate.waitForHeldServerMessage("send_agent_message_response");
|
||||
await expect(page.getByText("hello", { exact: true })).toHaveCount(1);
|
||||
await expect(page.getByText(/^Worked for/)).toHaveCount(0);
|
||||
gate.releaseHeldServerMessage("send_agent_message_response");
|
||||
await expect(page.getByText("hello", { exact: true })).toHaveCount(1);
|
||||
} finally {
|
||||
gate.restore();
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("replays Claude-shaped steering inside one active turn", async ({ page }, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
await replaySteeredSleepTurnInBrowser(page, testInfo, "claude");
|
||||
});
|
||||
|
||||
test("replays Codex-shaped steering inside one active turn", async ({ page }, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
await replaySteeredSleepTurnInBrowser(page, testInfo, "codex");
|
||||
});
|
||||
|
||||
test("restores overlapping queued sends when their connection fails", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
|
||||
@@ -12,7 +12,7 @@ async function pressSettingsToggleShortcut(page: import("@playwright/test").Page
|
||||
|
||||
async function expectSendBehavior(
|
||||
page: import("@playwright/test").Page,
|
||||
expected: "interrupt" | "queue",
|
||||
expected: "interrupt" | "queue" | "steer",
|
||||
) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
|
||||
@@ -198,13 +198,20 @@ export async function selectGithubOption(
|
||||
export interface MockAgentSetup {
|
||||
client: SeedDaemonClient;
|
||||
repo: Awaited<ReturnType<typeof createTempGitRepo>>;
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Create a temp repo, start a mock agent, navigate to it, and wait for it to be running. */
|
||||
export async function startRunningMockAgent(
|
||||
page: Page,
|
||||
opts: { prefix: string; model: string; prompt: string },
|
||||
opts: {
|
||||
prefix: string;
|
||||
model: string;
|
||||
prompt: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<MockAgentSetup> {
|
||||
const serverId = getServerId();
|
||||
|
||||
@@ -222,6 +229,7 @@ export async function startRunningMockAgent(
|
||||
cwd: repo.path,
|
||||
workspaceId: workspace.id,
|
||||
model: opts.model,
|
||||
featureValues: opts.featureValues,
|
||||
});
|
||||
const agentUrl = `${buildHostWorkspaceRoute(serverId, workspace.id)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
|
||||
await page.goto(agentUrl);
|
||||
@@ -233,6 +241,8 @@ export async function startRunningMockAgent(
|
||||
return {
|
||||
client,
|
||||
repo,
|
||||
workspaceId: workspace.id,
|
||||
agentId: agent.id,
|
||||
cleanup: async () => {
|
||||
await client.removeProject(workspace.projectId).catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
|
||||
@@ -20,6 +20,9 @@ interface ClientRequest {
|
||||
payload?: unknown;
|
||||
mode?: unknown;
|
||||
path?: unknown;
|
||||
agentId?: unknown;
|
||||
text?: unknown;
|
||||
activeTurnBehavior?: unknown;
|
||||
}
|
||||
|
||||
interface ServerMessage {
|
||||
@@ -48,6 +51,11 @@ interface HeldServerMessage {
|
||||
blockedAgentId?: string;
|
||||
}
|
||||
|
||||
interface PendingServerMessageHold {
|
||||
matches: (message: ClientRequest | null) => boolean;
|
||||
blockAgentStreamFollowers?: boolean;
|
||||
}
|
||||
|
||||
function readSessionMessage(message: string | Buffer): ClientRequest | null {
|
||||
try {
|
||||
const envelope = JSON.parse(typeof message === "string" ? message : message.toString()) as {
|
||||
@@ -125,6 +133,25 @@ function forceTimelineReset(message: string | Buffer, enabled: boolean): string
|
||||
return JSON.stringify(envelope);
|
||||
}
|
||||
|
||||
function rewriteShellToolCommand(
|
||||
message: string | Buffer,
|
||||
command: string | null,
|
||||
): string | Buffer {
|
||||
if (!command || typeof message !== "string") return message;
|
||||
const envelope = JSON.parse(message) as {
|
||||
message?: { payload?: { event?: { type?: unknown; item?: Record<string, unknown> } } };
|
||||
payload?: { event?: { type?: unknown; item?: Record<string, unknown> } };
|
||||
};
|
||||
const event = (envelope.message?.payload ?? envelope.payload)?.event;
|
||||
if (event?.type !== "timeline" || event.item?.type !== "tool_call") return message;
|
||||
const detail = event.item.detail;
|
||||
if (!detail || typeof detail !== "object" || (detail as { type?: unknown }).type !== "shell") {
|
||||
return message;
|
||||
}
|
||||
(detail as { command?: unknown }).command = command;
|
||||
return JSON.stringify(envelope);
|
||||
}
|
||||
|
||||
function readAgentStreamEventType(message: ClientRequest | null): string | null {
|
||||
if (message?.type !== "agent_stream" || !message.payload || typeof message.payload !== "object") {
|
||||
return null;
|
||||
@@ -152,6 +179,36 @@ function readAgentStreamItemType(message: ClientRequest | null): string | null {
|
||||
: null;
|
||||
}
|
||||
|
||||
function matchesToolCall(
|
||||
message: ClientRequest | null,
|
||||
input: { status: string; command?: string },
|
||||
): boolean {
|
||||
if (readAgentStreamItemType(message) !== "tool_call") return false;
|
||||
const event = (
|
||||
message?.payload as
|
||||
| {
|
||||
event?: { item?: { status?: unknown; detail?: { command?: unknown } } };
|
||||
}
|
||||
| undefined
|
||||
)?.event;
|
||||
return (
|
||||
event?.item?.status === input.status &&
|
||||
(input.command === undefined || event.item.detail?.command === input.command)
|
||||
);
|
||||
}
|
||||
|
||||
function matchesShellToolCall(message: ClientRequest | null, status: string): boolean {
|
||||
if (readAgentStreamItemType(message) !== "tool_call") return false;
|
||||
const event = (
|
||||
message?.payload as
|
||||
| {
|
||||
event?: { item?: { status?: unknown; detail?: { type?: unknown } } };
|
||||
}
|
||||
| undefined
|
||||
)?.event;
|
||||
return event?.item?.status === status && event.item.detail?.type === "shell";
|
||||
}
|
||||
|
||||
function shouldSuppressServerMessage(input: {
|
||||
message: ClientRequest | null;
|
||||
messageTypes: ReadonlySet<string>;
|
||||
@@ -226,10 +283,11 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
let forceTimelineEpochReset = false;
|
||||
let stripAssistantMessageIds = false;
|
||||
let stripCanonicalSubmittedPromptsFeature = false;
|
||||
let shellToolCommandOverride: string | null = null;
|
||||
let heldClientRequestType: string | null = null;
|
||||
let heldClientRequest: { server: WebSocketRoute; message: string | Buffer } | null = null;
|
||||
let resolveHeldClientRequest: (() => void) | null = null;
|
||||
const pendingServerMessageHolds = new Map<string, (message: ClientRequest | null) => boolean>();
|
||||
const pendingServerMessageHolds = new Map<string, PendingServerMessageHold>();
|
||||
const heldServerMessages: HeldServerMessage[] = [];
|
||||
const heldServerMessageWaiters = new Set<() => void>();
|
||||
const suppressedServerMessageTypes = new Set<string>();
|
||||
@@ -243,6 +301,7 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
total: { agents: 0, workspaces: 0 },
|
||||
};
|
||||
const clientRequestCounts = new Map<string, number>();
|
||||
const clientRequests = new Map<string, ClientRequest[]>();
|
||||
const timelineRequestCounts = new Map<string, number>();
|
||||
const serverMessageCounts = new Map<string, number>();
|
||||
const agentStreamEventCounts = new Map<string, number>();
|
||||
@@ -322,11 +381,11 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
if (!suppressed) blocked.agentStreamFollowers.push(input.message);
|
||||
return true;
|
||||
}
|
||||
const matchedHold = Array.from(pendingServerMessageHolds).find(([, matches]) =>
|
||||
matches(input.parsed),
|
||||
const matchedHold = Array.from(pendingServerMessageHolds).find(([, hold]) =>
|
||||
hold.matches(input.parsed),
|
||||
);
|
||||
if (!matchedHold) return suppressed;
|
||||
const [key] = matchedHold;
|
||||
const [key, hold] = matchedHold;
|
||||
pendingServerMessageHolds.delete(key);
|
||||
heldServerMessages.push({
|
||||
browser: input.browser,
|
||||
@@ -334,7 +393,7 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
key,
|
||||
agentStreamFollowers: [],
|
||||
blockedAgentId:
|
||||
readAgentStreamEventType(input.parsed) === "turn_started"
|
||||
hold.blockAgentStreamFollowers || readAgentStreamEventType(input.parsed) === "turn_started"
|
||||
? (agentId ?? undefined)
|
||||
: undefined,
|
||||
});
|
||||
@@ -385,6 +444,11 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
}
|
||||
const request = readClientRequest(message);
|
||||
recordClientRequest(request, clientRequestCounts, timelineRequestCounts, directoryStarts);
|
||||
if (typeof request?.type === "string") {
|
||||
const requests = clientRequests.get(request.type) ?? [];
|
||||
requests.push(request);
|
||||
clientRequests.set(request.type, requests);
|
||||
}
|
||||
if (request?.type === heldClientRequestType) {
|
||||
heldClientRequest = { server, message };
|
||||
resolveHeldClientRequest?.();
|
||||
@@ -417,6 +481,7 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
stripAssistantMessageIds,
|
||||
serverMessage?.type,
|
||||
);
|
||||
outboundMessage = rewriteShellToolCommand(outboundMessage, shellToolCommandOverride);
|
||||
outboundMessage = stripCanonicalSubmittedPrompts(
|
||||
outboundMessage,
|
||||
stripCanonicalSubmittedPromptsFeature,
|
||||
@@ -516,25 +581,36 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
heldClientRequestType = null;
|
||||
},
|
||||
holdNextServerMessage(type: string): void {
|
||||
pendingServerMessageHolds.set(serverMessageKey(type), (message) => message?.type === type);
|
||||
pendingServerMessageHolds.set(serverMessageKey(type), {
|
||||
matches: (message) => message?.type === type,
|
||||
});
|
||||
},
|
||||
holdNextAgentUpdate(agentId: string, status: string): void {
|
||||
const heldAgentUpdate = { agentId, status };
|
||||
pendingServerMessageHolds.set(agentUpdateKey(agentId, status), (message) =>
|
||||
matchesAgentUpdate(message, heldAgentUpdate),
|
||||
);
|
||||
pendingServerMessageHolds.set(agentUpdateKey(agentId, status), {
|
||||
matches: (message) => matchesAgentUpdate(message, heldAgentUpdate),
|
||||
});
|
||||
},
|
||||
holdNextAgentStreamEvent(type: string): void {
|
||||
pendingServerMessageHolds.set(
|
||||
agentStreamEventKey(type),
|
||||
(message) => readAgentStreamEventType(message) === type,
|
||||
);
|
||||
pendingServerMessageHolds.set(agentStreamEventKey(type), {
|
||||
matches: (message) => readAgentStreamEventType(message) === type,
|
||||
});
|
||||
},
|
||||
holdNextAgentStreamItem(type: string): void {
|
||||
pendingServerMessageHolds.set(
|
||||
agentStreamItemKey(type),
|
||||
(message) => readAgentStreamItemType(message) === type,
|
||||
);
|
||||
pendingServerMessageHolds.set(agentStreamItemKey(type), {
|
||||
matches: (message) => readAgentStreamItemType(message) === type,
|
||||
});
|
||||
},
|
||||
holdNextToolCall(input: { status: string; command?: string }): void {
|
||||
pendingServerMessageHolds.set(`tool-call:${input.status}:${input.command ?? ""}`, {
|
||||
matches: (message) => matchesToolCall(message, input),
|
||||
});
|
||||
},
|
||||
holdNextShellToolCall(status: string): void {
|
||||
pendingServerMessageHolds.set(`shell-tool-call:${status}`, {
|
||||
matches: (message) => matchesShellToolCall(message, status),
|
||||
blockAgentStreamFollowers: true,
|
||||
});
|
||||
},
|
||||
async waitForHeldServerMessage(type?: string): Promise<void> {
|
||||
const key = type ? serverMessageKey(type) : null;
|
||||
@@ -686,6 +762,9 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
setCanonicalSubmittedPromptsStripped(stripped: boolean): void {
|
||||
stripCanonicalSubmittedPromptsFeature = stripped;
|
||||
},
|
||||
setShellToolCommandOverride(command: string | null): void {
|
||||
shellToolCommandOverride = command;
|
||||
},
|
||||
setAgentStreamSuppressed(suppressed: boolean): void {
|
||||
suppressAgentStream = suppressed;
|
||||
},
|
||||
@@ -702,6 +781,9 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
getClientRequestCount(type: string): number {
|
||||
return clientRequestCounts.get(type) ?? 0;
|
||||
},
|
||||
getClientRequests(type: string): ReadonlyArray<ClientRequest> {
|
||||
return [...(clientRequests.get(type) ?? [])];
|
||||
},
|
||||
getTimelineRequestCount(direction: "tail" | "before" | "after"): number {
|
||||
return timelineRequestCounts.get(direction) ?? 0;
|
||||
},
|
||||
|
||||
@@ -26,6 +26,7 @@ function assistantMessage(
|
||||
id: string,
|
||||
seed: number,
|
||||
block?: { groupId: string; index: number },
|
||||
turnId?: string,
|
||||
): Extract<StreamItem, { kind: "assistant_message" }> {
|
||||
return {
|
||||
kind: "assistant_message",
|
||||
@@ -33,14 +34,20 @@ function assistantMessage(
|
||||
text: id,
|
||||
timestamp: timestamp(seed),
|
||||
...(block ? { blockGroupId: block.groupId, blockIndex: block.index } : {}),
|
||||
...(turnId ? { turnId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function toolCall(id: string, seed: number): Extract<StreamItem, { kind: "tool_call" }> {
|
||||
function toolCall(
|
||||
id: string,
|
||||
seed: number,
|
||||
turnId?: string,
|
||||
): Extract<StreamItem, { kind: "tool_call" }> {
|
||||
return {
|
||||
kind: "tool_call",
|
||||
id,
|
||||
timestamp: timestamp(seed),
|
||||
...(turnId ? { turnId } : {}),
|
||||
payload: {
|
||||
source: "orchestrator",
|
||||
data: {
|
||||
@@ -143,6 +150,44 @@ function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem {
|
||||
}
|
||||
|
||||
describe("layoutStream", () => {
|
||||
it("does not borrow an assistant footer across adjacent tagged tool-only turns", () => {
|
||||
const priorAssistant = assistantMessage("prior", 1, undefined, "turn-1");
|
||||
const nextTool = toolCall("next-tool", 2, "turn-2");
|
||||
const layout = layoutFor({
|
||||
platform: "web",
|
||||
tail: [priorAssistant, nextTool],
|
||||
timingIds: [priorAssistant.id],
|
||||
});
|
||||
|
||||
expect(layout.auxiliaryTurnFooter).toBeNull();
|
||||
expect(footerOwners(layout)).toEqual([priorAssistant.id]);
|
||||
});
|
||||
|
||||
it("recomputes cached history layout when only the live boundary turn changes", () => {
|
||||
const priorAssistant = assistantMessage("prior", 1, undefined, "turn-1");
|
||||
const history = [priorAssistant];
|
||||
const liveHead = [toolCall("live-tool", 2, "turn-1")];
|
||||
const strategy = strategyFor("web");
|
||||
const first = layoutStream({
|
||||
strategy,
|
||||
isTurnActive: true,
|
||||
history,
|
||||
liveHead,
|
||||
timingByAssistantId: timingFor(priorAssistant.id),
|
||||
});
|
||||
|
||||
liveHead[0] = { ...liveHead[0]!, turnId: "turn-2" };
|
||||
const second = layoutStream({
|
||||
strategy,
|
||||
isTurnActive: true,
|
||||
history,
|
||||
liveHead,
|
||||
timingByAssistantId: timingFor(priorAssistant.id),
|
||||
});
|
||||
|
||||
expect(footerOwners(first)).toEqual([]);
|
||||
expect(footerOwners(second)).toEqual([priorAssistant.id]);
|
||||
});
|
||||
it.each(["web", "android"] as const)(
|
||||
"marks only the active live-head assistant block as streaming on %s",
|
||||
(platform) => {
|
||||
@@ -249,6 +294,18 @@ describe("layoutStream", () => {
|
||||
expect(assistantRow.frameOrder).toBe("footer-then-content");
|
||||
});
|
||||
|
||||
it("keeps an accepted steer inline while its canonical turn is active", () => {
|
||||
const assistant = { ...assistantMessage("a1", 2), turnId: "turn-1" };
|
||||
const steer = { ...userMessage("u2", 3), turnId: "turn-1" };
|
||||
const layout = layoutFor({
|
||||
platform: "web",
|
||||
isTurnActive: true,
|
||||
tail: [{ ...userMessage("u1", 1), turnId: "turn-1" }, assistant, steer],
|
||||
timingIds: [assistant.id],
|
||||
});
|
||||
expect(footerOwners(layout)).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps forward stream content before its completed footer", () => {
|
||||
const assistant = assistantMessage("a1", 2);
|
||||
const layout = layoutFor({
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { TurnTiming } from "@/timeline/turn-time";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { getAssistantBlockSpacing, getGapBetweenStreamItems } from "./spacing";
|
||||
import type { StreamFrameChildOrder, StreamStrategy } from "./strategy";
|
||||
import { continuesTurn, isTurnBoundary } from "./turn-membership";
|
||||
|
||||
export type StreamToolSequence = "single" | "first" | "middle" | "last" | "none";
|
||||
|
||||
@@ -87,6 +88,7 @@ function findLatestAssistantInTurn(input: {
|
||||
let items = input.items;
|
||||
let index = input.startIndex;
|
||||
let canCrossBoundary = true;
|
||||
let laterItem: StreamItem | null = null;
|
||||
|
||||
while (true) {
|
||||
for (
|
||||
@@ -95,12 +97,13 @@ function findLatestAssistantInTurn(input: {
|
||||
index = input.strategy.getNeighborIndex(index, "above")
|
||||
) {
|
||||
const item = items[index];
|
||||
if (!item || item.kind === "user_message") {
|
||||
if (!item || (laterItem && !continuesTurn(item, laterItem))) {
|
||||
return null;
|
||||
}
|
||||
if (item.kind === "assistant_message") {
|
||||
return { item, items, index };
|
||||
}
|
||||
laterItem = item;
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -157,7 +160,7 @@ function resolveCompletedFooter(input: {
|
||||
boundaryAboveItems: StreamItem[] | null;
|
||||
boundaryAboveIndex: number | null;
|
||||
}): TurnFooterHost | null {
|
||||
if (input.item.kind === "user_message" || input.belowItem?.kind !== "user_message") {
|
||||
if (input.item.kind === "user_message" || !isTurnBoundary(input.item, input.belowItem)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -194,8 +197,10 @@ function getToolSequence(input: {
|
||||
return "none";
|
||||
}
|
||||
|
||||
const hasAbove = isToolSequenceItem(input.aboveItem);
|
||||
const hasBelow = isToolSequenceItem(input.belowItem);
|
||||
const hasAbove =
|
||||
isToolSequenceItem(input.aboveItem) && continuesTurn(input.aboveItem, input.item);
|
||||
const hasBelow =
|
||||
isToolSequenceItem(input.belowItem) && continuesTurn(input.item, input.belowItem);
|
||||
if (hasAbove && hasBelow) {
|
||||
return "middle";
|
||||
}
|
||||
@@ -273,7 +278,9 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
|
||||
toolSequence: getToolSequence({ item, aboveItem, belowItem }),
|
||||
isFirstInUserGroup: item.kind === "user_message" && aboveItem?.kind !== "user_message",
|
||||
isLastInUserGroup: item.kind === "user_message" && belowItem?.kind !== "user_message",
|
||||
isLastInToolSequence: isToolSequenceItem(item) && !isToolSequenceItem(belowItem),
|
||||
isLastInToolSequence:
|
||||
isToolSequenceItem(item) &&
|
||||
!(isToolSequenceItem(belowItem) && continuesTurn(item, belowItem)),
|
||||
frameOrder: input.frameOrder,
|
||||
phase: input.phase,
|
||||
};
|
||||
@@ -297,14 +304,14 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
|
||||
let history: StreamLayoutItem[];
|
||||
if (input.history.length > 0) {
|
||||
// The cache key encodes every input that can change history layout. liveHeadBoundaryItem.id
|
||||
// and .kind are stable across text-only flushes (text growth doesn't change what kind of
|
||||
// item borders history), so cached layout stays valid between flushes.
|
||||
// The cache key encodes every input that can change history layout. The boundary turn ID
|
||||
// is membership, so changing it must not reuse a layout from the adjacent turn.
|
||||
const historyCacheKey = [
|
||||
frameOrder,
|
||||
historyBoundaryIndex ?? "null",
|
||||
liveHeadBoundaryItem?.id ?? "null",
|
||||
liveHeadBoundaryItem?.kind ?? "null",
|
||||
liveHeadBoundaryItem?.turnId ?? "null",
|
||||
auxiliaryTurnFooter?.itemId ?? "null",
|
||||
].join(":");
|
||||
let byKey = historyLayoutCache.get(input.history);
|
||||
|
||||
@@ -38,6 +38,24 @@ function assistantMessage(id: string, text: string, seed: number): StreamItem {
|
||||
};
|
||||
}
|
||||
|
||||
function toolCall(id: string, seed: number): StreamItem {
|
||||
return {
|
||||
kind: "tool_call",
|
||||
id,
|
||||
timestamp: createTimestamp(seed),
|
||||
payload: {
|
||||
source: "orchestrator",
|
||||
data: {
|
||||
toolCallId: id,
|
||||
toolName: "Shell",
|
||||
arguments: "echo hi",
|
||||
result: null,
|
||||
status: "completed",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveStreamRenderStrategy", () => {
|
||||
it("uses forward_stream on web", () => {
|
||||
const strategy = resolveStreamRenderStrategy({
|
||||
@@ -196,6 +214,23 @@ describe("neighbor and traversal semantics", () => {
|
||||
).toBe("assistant-1\n\nassistant-2");
|
||||
});
|
||||
|
||||
it("does not collect copy or fork content across an adjacent tagged turn", () => {
|
||||
const chronological: StreamItem[] = [
|
||||
{ ...assistantMessage("a1", "first turn", 1), turnId: "turn-1" },
|
||||
{ ...toolCall("tool", 2), turnId: "turn-2" },
|
||||
{ ...assistantMessage("a2", "second turn", 3), turnId: "turn-2" },
|
||||
];
|
||||
const strategy = resolveStreamRenderStrategy({ platform: "web", isMobileBreakpoint: false });
|
||||
|
||||
expect(
|
||||
collectAssistantTurnContentForStreamRenderStrategy({
|
||||
strategy,
|
||||
items: chronological,
|
||||
startIndex: 2,
|
||||
}),
|
||||
).toBe("second turn");
|
||||
});
|
||||
|
||||
it("returns undefined neighbor when index would be out of bounds", () => {
|
||||
const forward = resolveStreamRenderStrategy({
|
||||
platform: "web",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ComponentType, ReactElement, ReactNode, RefObject } from "react";
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { continuesTurn } from "./turn-membership";
|
||||
import type { StreamHistoryBoundary, StreamRenderSegments } from "./model";
|
||||
import type {
|
||||
BottomAnchorLocalRequest,
|
||||
@@ -165,18 +166,20 @@ export function createStreamStrategy(config: StreamStrategyConfig): StreamStrate
|
||||
},
|
||||
collectAssistantTurnContent: (items, startIndex) => {
|
||||
const messages: string[] = [];
|
||||
let laterItem: StreamItem | null = null;
|
||||
for (
|
||||
let index = startIndex;
|
||||
index >= 0 && index < items.length;
|
||||
index += config.assistantTurnTraversalStep
|
||||
) {
|
||||
const currentItem = items[index];
|
||||
if (currentItem.kind === "user_message") {
|
||||
if (!currentItem || (laterItem && !continuesTurn(currentItem, laterItem))) {
|
||||
break;
|
||||
}
|
||||
if (currentItem.kind === "assistant_message") {
|
||||
messages.push(currentItem.text);
|
||||
}
|
||||
laterItem = currentItem;
|
||||
}
|
||||
return messages.toReversed().join("\n\n");
|
||||
},
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { buildAgentStreamRenderModel } from "./model";
|
||||
import { layoutStream } from "./layout";
|
||||
import { resolveStreamRenderStrategy } from "./strategy-resolver";
|
||||
import { continuesTurn } from "./turn-membership";
|
||||
|
||||
function at(second: number): Date {
|
||||
return new Date(`2026-01-01T00:00:${second.toString().padStart(2, "0")}.000Z`);
|
||||
}
|
||||
|
||||
function user(id: string, second: number, turnId: string): StreamItem {
|
||||
return { kind: "user_message", id, text: id, timestamp: at(second), turnId };
|
||||
}
|
||||
|
||||
function assistant(id: string, second: number, turnId: string): StreamItem {
|
||||
return { kind: "assistant_message", id, text: id, timestamp: at(second), turnId };
|
||||
}
|
||||
|
||||
function runningTool(id: string, second: number, turnId: string): StreamItem {
|
||||
return {
|
||||
kind: "tool_call",
|
||||
id,
|
||||
timestamp: at(second),
|
||||
turnId,
|
||||
payload: {
|
||||
source: "agent",
|
||||
data: {
|
||||
provider: "claude",
|
||||
callId: id,
|
||||
name: "Bash",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: { type: "unknown", input: null, output: null },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function layoutFor(items: StreamItem[], isTurnActive: boolean) {
|
||||
const model = buildAgentStreamRenderModel({
|
||||
isTurnActive,
|
||||
activeTurnStartedAt: isTurnActive ? at(1) : null,
|
||||
tail: items,
|
||||
head: [],
|
||||
platform: "web",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
return {
|
||||
model,
|
||||
layout: layoutStream({
|
||||
strategy: resolveStreamRenderStrategy({ platform: "web", isMobileBreakpoint: false }),
|
||||
isTurnActive,
|
||||
history: model.segments.historyMounted,
|
||||
liveHead: model.segments.liveHead,
|
||||
timingByAssistantId: model.turnTiming.byAssistantId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function completedFooterIds(layout: ReturnType<typeof layoutFor>["layout"]): string[] {
|
||||
return [
|
||||
...layout.history.flatMap((row) => (row.completedFooter ? [row.completedFooter.itemId] : [])),
|
||||
...layout.liveHead.flatMap((row) => (row.completedFooter ? [row.completedFooter.itemId] : [])),
|
||||
...(layout.auxiliaryTurnFooter ? [layout.auxiliaryTurnFooter.itemId] : []),
|
||||
];
|
||||
}
|
||||
|
||||
describe("canonical turn membership", () => {
|
||||
it("stops tagged traversal at every mismatching turn ID", () => {
|
||||
expect(continuesTurn(assistant("first", 1, "turn-1"), runningTool("next", 2, "turn-2"))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(continuesTurn(runningTool("first", 1, "turn-1"), assistant("next", 2, "turn-1"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"Claude",
|
||||
(turnId: string) => [
|
||||
user("prompt", 1, turnId),
|
||||
runningTool("sleep", 2, turnId),
|
||||
user("hello", 3, turnId),
|
||||
],
|
||||
],
|
||||
[
|
||||
"Codex",
|
||||
(turnId: string) => [
|
||||
user("prompt", 1, turnId),
|
||||
assistant("preface", 2, turnId),
|
||||
runningTool("sleep", 3, turnId),
|
||||
user("hello", 4, turnId),
|
||||
],
|
||||
],
|
||||
] as const)("keeps %s-shaped active steers in one visible turn", (_provider, build) => {
|
||||
const turnId = "turn-1";
|
||||
const active = layoutFor(build(turnId), true);
|
||||
|
||||
expect(completedFooterIds(active.layout)).toEqual([]);
|
||||
expect(active.model.turnTiming.runningStartedAt).toEqual(at(1));
|
||||
|
||||
const completedItems = [...build(turnId), assistant("done", 9, turnId)];
|
||||
const completed = layoutFor(completedItems, false);
|
||||
expect(completedFooterIds(completed.layout)).toEqual(["done"]);
|
||||
expect(completed.model.turnTiming.byAssistantId.get("done")).toMatchObject({
|
||||
startedAt: at(1),
|
||||
completedAt: at(9),
|
||||
durationMs: 8000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
|
||||
/**
|
||||
* Canonical turn IDs take precedence. Timelines without them retain the legacy
|
||||
* user-message boundary rule so old daemons and persisted rows need no rewrite.
|
||||
*/
|
||||
export function continuesTurn(previous: StreamItem | null, next: StreamItem | null): boolean {
|
||||
if (!previous || !next) return false;
|
||||
if (previous.turnId !== undefined && next.turnId !== undefined) {
|
||||
return previous.turnId === next.turnId;
|
||||
}
|
||||
return next.kind !== "user_message";
|
||||
}
|
||||
|
||||
export function isTurnBoundary(previous: StreamItem | null, next: StreamItem | null): boolean {
|
||||
return previous !== null && next !== null && !continuesTurn(previous, next);
|
||||
}
|
||||
|
||||
/** Whether `item` begins a new chronological turn after `previous`. */
|
||||
export function startsNewTurn(item: StreamItem, previous: StreamItem | null): boolean {
|
||||
return previous === null || isTurnBoundary(previous, item);
|
||||
}
|
||||
|
||||
export function belongsToTurn(item: StreamItem, turnId: string | null): boolean {
|
||||
return turnId !== null && item.turnId === turnId;
|
||||
}
|
||||
@@ -179,6 +179,7 @@ interface FakeSendCall {
|
||||
text: string;
|
||||
options: {
|
||||
messageId: string;
|
||||
activeTurnBehavior?: "interrupt" | "steer";
|
||||
images: Array<{ data: string; mimeType: string }>;
|
||||
attachments: AgentAttachment[];
|
||||
};
|
||||
@@ -417,6 +418,50 @@ describe("pickAndPersistImages", () => {
|
||||
});
|
||||
|
||||
describe("dispatchComposerAgentMessage", () => {
|
||||
it("forwards the configured active-turn intent without provider capability checks", async () => {
|
||||
const client = createFakeSendClient();
|
||||
const stream = createFakeStream();
|
||||
|
||||
await dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId: "agent",
|
||||
text: "steer this turn",
|
||||
attachments: [],
|
||||
encodeImages: async () => [],
|
||||
submission: stream,
|
||||
activeTurnBehavior: "steer",
|
||||
});
|
||||
|
||||
expect(client.calls[0]?.options.activeTurnBehavior).toBe("steer");
|
||||
});
|
||||
|
||||
it("stamps only a steer optimistic row with the daemon active turn ID", async () => {
|
||||
const client = createFakeSendClient();
|
||||
const stream = createFakeStream();
|
||||
await dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId: "agent",
|
||||
text: "hello",
|
||||
attachments: [],
|
||||
encodeImages: async () => [],
|
||||
submission: stream,
|
||||
activeTurnBehavior: "steer",
|
||||
activeTurnId: "turn-1",
|
||||
});
|
||||
expect(stream.tail.get("agent")?.[0]).toMatchObject({ turnId: "turn-1" });
|
||||
const legacy = createFakeStream();
|
||||
await dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId: "legacy",
|
||||
text: "hello",
|
||||
attachments: [],
|
||||
encodeImages: async () => [],
|
||||
submission: legacy,
|
||||
activeTurnBehavior: "steer",
|
||||
});
|
||||
expect(legacy.tail.get("legacy")?.[0]?.turnId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("removes the submitted prompt when the host rejects it", async () => {
|
||||
const rejection = new Error("Host rejected prompt");
|
||||
const client = createFakeSendClient({ rejection });
|
||||
@@ -477,6 +522,28 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
).rejects.toBe(transportError);
|
||||
});
|
||||
|
||||
it("surfaces an ambiguous failure even when a concurrent canonical echo was observed", async () => {
|
||||
const transportError = new Error("Connection lost after delivery may have occurred");
|
||||
const client = createFakeSendClient({ rejection: transportError });
|
||||
const submission: MessageSubmissionWriter = {
|
||||
begin: () => {},
|
||||
accept: () => {},
|
||||
reject: () => "accepted",
|
||||
};
|
||||
|
||||
await expect(
|
||||
dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId: "agent",
|
||||
text: "ambiguous steer",
|
||||
attachments: [],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
submission,
|
||||
activeTurnBehavior: "steer",
|
||||
}),
|
||||
).rejects.toBe(transportError);
|
||||
});
|
||||
|
||||
it("sends text + image data + structured attachments and appends user_message to the tail when head is empty", async () => {
|
||||
const client = createFakeSendClient();
|
||||
const stream = createFakeStream();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
|
||||
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
|
||||
import type {
|
||||
AttachmentMetadata,
|
||||
ComposerAttachment,
|
||||
@@ -48,6 +49,7 @@ export interface ComposerSendClient {
|
||||
text: string,
|
||||
options: {
|
||||
messageId: string;
|
||||
activeTurnBehavior?: ActiveTurnBehavior;
|
||||
images: Array<{ data: string; mimeType: string }>;
|
||||
attachments: ReturnType<typeof splitComposerAttachmentsForSubmit>["attachments"];
|
||||
},
|
||||
@@ -176,6 +178,8 @@ export interface DispatchComposerAgentMessageInput {
|
||||
images: AttachmentMetadata[],
|
||||
) => Promise<Array<{ data: string; mimeType: string }> | undefined>;
|
||||
submission: MessageSubmissionWriter;
|
||||
activeTurnBehavior?: ActiveTurnBehavior;
|
||||
activeTurnId?: string;
|
||||
}
|
||||
|
||||
export async function dispatchComposerAgentMessage(
|
||||
@@ -191,19 +195,22 @@ export async function dispatchComposerAgentMessage(
|
||||
timestamp: new Date(),
|
||||
images: wirePayload.images,
|
||||
attachments: wirePayload.attachments,
|
||||
...(input.activeTurnBehavior === "steer" && input.activeTurnId
|
||||
? { turnId: input.activeTurnId }
|
||||
: {}),
|
||||
});
|
||||
input.submission.begin(input.agentId, userMessage);
|
||||
try {
|
||||
const imagesData = await input.encodeImages(wirePayload.images);
|
||||
await input.client.sendAgentMessage(input.agentId, input.text, {
|
||||
messageId: clientMessageId,
|
||||
...(input.activeTurnBehavior ? { activeTurnBehavior: input.activeTurnBehavior } : {}),
|
||||
images: imagesData ?? [],
|
||||
attachments: wirePayload.attachments,
|
||||
});
|
||||
input.submission.accept(input.agentId, clientMessageId);
|
||||
} catch (error) {
|
||||
const outcome = input.submission.reject(input.agentId, clientMessageId);
|
||||
if (outcome === "accepted") return;
|
||||
input.submission.reject(input.agentId, clientMessageId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1324,12 +1324,6 @@ function ComposerContentImpl({
|
||||
autocompleteOnKeyPressRef.current = autocomplete.onKeyPress;
|
||||
|
||||
// Clear send error when user edits the input
|
||||
useEffect(() => {
|
||||
if (sendError && userInput) {
|
||||
setSendError(null);
|
||||
}
|
||||
}, [userInput, sendError]);
|
||||
|
||||
useEffect(() => {
|
||||
setCursorIndex((current) => Math.min(current, userInput.length));
|
||||
}, [userInput.length]);
|
||||
@@ -1338,7 +1332,13 @@ function ComposerContentImpl({
|
||||
const { pickFiles } = useFilePicker();
|
||||
const agentIdRef = useRef(agentId);
|
||||
const sendAgentMessageRef = useRef<
|
||||
((agentId: string, text: string, attachments: ComposerAttachment[]) => Promise<void>) | null
|
||||
| ((
|
||||
agentId: string,
|
||||
text: string,
|
||||
attachments: ComposerAttachment[],
|
||||
activeTurnBehavior: "interrupt" | "steer",
|
||||
) => Promise<void>)
|
||||
| null
|
||||
>(null);
|
||||
const onSubmitMessageRef = useRef(onSubmitMessage);
|
||||
|
||||
@@ -1399,9 +1399,14 @@ function ComposerContentImpl({
|
||||
if (!sendAgentMessageRef.current) {
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
await sendAgentMessageRef.current(agentIdRef.current, text, submitAttachments);
|
||||
await sendAgentMessageRef.current(
|
||||
agentIdRef.current,
|
||||
text,
|
||||
submitAttachments,
|
||||
appSettings.sendBehavior === "steer" ? "steer" : "interrupt",
|
||||
);
|
||||
},
|
||||
[cwd, onMessageSent, t],
|
||||
[appSettings.sendBehavior, cwd, onMessageSent, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1413,6 +1418,7 @@ function ComposerContentImpl({
|
||||
targetAgentId: string,
|
||||
text: string,
|
||||
sendAttachments: ComposerAttachment[],
|
||||
activeTurnBehavior: "interrupt" | "steer",
|
||||
) => {
|
||||
if (!client) {
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
@@ -1427,10 +1433,16 @@ function ComposerContentImpl({
|
||||
}),
|
||||
encodeImages,
|
||||
submission: createMessageSubmissionWriter(serverId),
|
||||
activeTurnBehavior,
|
||||
activeTurnId:
|
||||
activeTurnBehavior === "steer"
|
||||
? (useSessionStore.getState().sessions[serverId]?.agents.get(targetAgentId)?.activeTurn
|
||||
?.turnId ?? undefined)
|
||||
: undefined,
|
||||
});
|
||||
onAttentionPromptSend?.();
|
||||
};
|
||||
}, [client, onAttentionPromptSend, serverId, supportsForgeSearch, t]);
|
||||
}, [appSettings.sendBehavior, client, onAttentionPromptSend, serverId, supportsForgeSearch, t]);
|
||||
|
||||
useEffect(() => {
|
||||
onSubmitMessageRef.current = onSubmitMessage;
|
||||
@@ -2185,7 +2197,12 @@ function ComposerContentImpl({
|
||||
const messageInputAutoFocus = autoFocus && isDesktopWebBreakpoint;
|
||||
const submitLoadingPressHandler = isAgentRunning ? handleCancelAgent : undefined;
|
||||
const sendErrorNode = useMemo(
|
||||
() => (sendError ? <Text style={styles.sendErrorText}>{sendError}</Text> : null),
|
||||
() =>
|
||||
sendError ? (
|
||||
<Text accessibilityRole="alert" style={styles.sendErrorText}>
|
||||
{sendError}
|
||||
</Text>
|
||||
) : null,
|
||||
[sendError],
|
||||
);
|
||||
const githubEmptyText = githubSearchResultsQuery.isFetching
|
||||
|
||||
@@ -131,7 +131,7 @@ export interface MessageInputProps {
|
||||
isAgentRunning?: boolean;
|
||||
/** Controls what the default send action (Enter, send button, dictation) does
|
||||
* when the agent is running. "interrupt" sends immediately, "queue" queues. */
|
||||
defaultSendBehavior?: "interrupt" | "queue";
|
||||
defaultSendBehavior?: "interrupt" | "steer" | "queue";
|
||||
/** Callback for queue button when agent is running */
|
||||
onQueue?: (payload: MessagePayload) => void;
|
||||
/** Optional handler used when submit button is in loading state. */
|
||||
@@ -1002,7 +1002,7 @@ interface SendButtonStateInput {
|
||||
isSubmitDisabled: boolean;
|
||||
isSubmitLoading: boolean;
|
||||
onSubmitLoadingPress: (() => void) | undefined;
|
||||
defaultSendBehavior: "interrupt" | "queue";
|
||||
defaultSendBehavior: "interrupt" | "steer" | "queue";
|
||||
isAgentRunning: boolean;
|
||||
}
|
||||
|
||||
@@ -1052,7 +1052,7 @@ interface ResolvedMessageInputProps {
|
||||
voiceServerId: string | undefined;
|
||||
voiceAgentId: string | undefined;
|
||||
isAgentRunning: boolean;
|
||||
defaultSendBehavior: "interrupt" | "queue";
|
||||
defaultSendBehavior: "interrupt" | "steer" | "queue";
|
||||
onQueue: ((payload: MessagePayload) => void) | undefined;
|
||||
onSubmitLoadingPress: (() => void) | undefined;
|
||||
onKeyPressCallback: ((event: { key: string; preventDefault: () => void }) => boolean) | undefined;
|
||||
@@ -1608,6 +1608,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
submitButtonAccessibilityLabel,
|
||||
canPressLoadingButton,
|
||||
defaultActionQueues,
|
||||
defaultSendBehavior,
|
||||
isAgentRunning,
|
||||
t,
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ const translations: Record<string, string> = {
|
||||
"composer.input.interruptAgent": "Interrupt agent",
|
||||
"composer.input.queueMessage": "Queue message",
|
||||
"composer.input.sendAndInterrupt": "Send and interrupt",
|
||||
"composer.input.sendAndSteer": "Send and steer",
|
||||
"composer.input.sendMessage": "Send message",
|
||||
"composer.input.queue": "Queue",
|
||||
"composer.input.send": "Send",
|
||||
@@ -31,6 +32,7 @@ describe("composer input labels", () => {
|
||||
submitButtonAccessibilityLabel: undefined,
|
||||
canPressLoadingButton: true,
|
||||
defaultActionQueues: false,
|
||||
defaultSendBehavior: "interrupt",
|
||||
isAgentRunning: true,
|
||||
t,
|
||||
}),
|
||||
@@ -40,6 +42,7 @@ describe("composer input labels", () => {
|
||||
submitButtonAccessibilityLabel: undefined,
|
||||
canPressLoadingButton: false,
|
||||
defaultActionQueues: true,
|
||||
defaultSendBehavior: "queue",
|
||||
isAgentRunning: true,
|
||||
t,
|
||||
}),
|
||||
@@ -49,6 +52,7 @@ describe("composer input labels", () => {
|
||||
submitButtonAccessibilityLabel: undefined,
|
||||
canPressLoadingButton: false,
|
||||
defaultActionQueues: false,
|
||||
defaultSendBehavior: "interrupt",
|
||||
isAgentRunning: true,
|
||||
t,
|
||||
}),
|
||||
@@ -58,6 +62,17 @@ describe("composer input labels", () => {
|
||||
submitButtonAccessibilityLabel: undefined,
|
||||
canPressLoadingButton: false,
|
||||
defaultActionQueues: false,
|
||||
defaultSendBehavior: "steer",
|
||||
isAgentRunning: true,
|
||||
t,
|
||||
}),
|
||||
).toBe("Send and steer");
|
||||
expect(
|
||||
resolveSubmitAccessibilityLabel({
|
||||
submitButtonAccessibilityLabel: undefined,
|
||||
canPressLoadingButton: false,
|
||||
defaultActionQueues: false,
|
||||
defaultSendBehavior: "interrupt",
|
||||
isAgentRunning: false,
|
||||
t,
|
||||
}),
|
||||
@@ -70,6 +85,7 @@ describe("composer input labels", () => {
|
||||
submitButtonAccessibilityLabel: "Run now",
|
||||
canPressLoadingButton: false,
|
||||
defaultActionQueues: false,
|
||||
defaultSendBehavior: "interrupt",
|
||||
isAgentRunning: false,
|
||||
t,
|
||||
}),
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import type { SendBehavior } from "@/hooks/use-settings/storage";
|
||||
|
||||
export function resolveSubmitAccessibilityLabel(input: {
|
||||
submitButtonAccessibilityLabel: string | undefined;
|
||||
canPressLoadingButton: boolean;
|
||||
defaultActionQueues: boolean;
|
||||
defaultSendBehavior: SendBehavior;
|
||||
isAgentRunning: boolean;
|
||||
t: TFunction;
|
||||
}): string {
|
||||
if (input.submitButtonAccessibilityLabel) return input.submitButtonAccessibilityLabel;
|
||||
if (input.canPressLoadingButton) return input.t("composer.input.interruptAgent");
|
||||
if (input.defaultActionQueues) return input.t("composer.input.queueMessage");
|
||||
if (input.isAgentRunning) return input.t("composer.input.sendAndInterrupt");
|
||||
if (input.isAgentRunning) {
|
||||
return input.t(
|
||||
input.defaultSendBehavior === "steer"
|
||||
? "composer.input.sendAndSteer"
|
||||
: "composer.input.sendAndInterrupt",
|
||||
);
|
||||
}
|
||||
return input.t("composer.input.sendMessage");
|
||||
}
|
||||
|
||||
|
||||
@@ -221,6 +221,29 @@ describe("composer send behavior", () => {
|
||||
expect(alternateAction.calls).toEqual(["queue"]);
|
||||
});
|
||||
|
||||
it("uses Enter to steer and Mod+Enter to queue when steer is selected", () => {
|
||||
const defaultAction = actions();
|
||||
runDefaultSendAction({
|
||||
defaultSendBehavior: "steer",
|
||||
isAgentRunning: true,
|
||||
onQueue: defaultAction.onQueue,
|
||||
handleSendMessage: defaultAction.handleSendMessage,
|
||||
handleQueueMessage: defaultAction.handleQueueMessage,
|
||||
});
|
||||
|
||||
const alternateAction = actions();
|
||||
runAlternateSendAction({
|
||||
defaultSendBehavior: "steer",
|
||||
isAgentRunning: true,
|
||||
onQueue: alternateAction.onQueue,
|
||||
handleSendMessage: alternateAction.handleSendMessage,
|
||||
handleQueueMessage: alternateAction.handleQueueMessage,
|
||||
});
|
||||
|
||||
expect(defaultAction.calls).toEqual(["send"]);
|
||||
expect(alternateAction.calls).toEqual(["queue"]);
|
||||
});
|
||||
|
||||
it("uses Enter to queue and Mod+Enter to submit when queue is selected", () => {
|
||||
const defaultAction = actions();
|
||||
runDefaultSendAction({
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
|
||||
import type { MessagePayload } from "@/composer/types";
|
||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||
|
||||
export type SendBehavior = "interrupt" | "queue";
|
||||
export type SendBehavior = ActiveTurnBehavior | "queue";
|
||||
|
||||
interface ComposerSurfaceState {
|
||||
opacity: 0 | 1;
|
||||
|
||||
@@ -184,6 +184,39 @@ describe("submitAgentInput", () => {
|
||||
expect(clearDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores a steered active-turn draft after an ambiguous immediate-send error", async () => {
|
||||
const error = new Error("connection lost after delivery");
|
||||
const setUserInput = vi.fn();
|
||||
const setAttachments = vi.fn();
|
||||
const setSendError = vi.fn();
|
||||
const setIsProcessing = vi.fn();
|
||||
|
||||
await expect(
|
||||
submitAgentInput({
|
||||
message: " steer this turn ",
|
||||
attachments: [{ id: "img-1" }],
|
||||
forceSend: true,
|
||||
isAgentRunning: true,
|
||||
canSubmit: true,
|
||||
queueMessage: vi.fn(),
|
||||
submitMessage: async () => {
|
||||
throw error;
|
||||
},
|
||||
clearDraft: vi.fn(),
|
||||
setUserInput,
|
||||
setAttachments,
|
||||
setSendError,
|
||||
setIsProcessing,
|
||||
}),
|
||||
).resolves.toBe("failed");
|
||||
|
||||
expect(setUserInput).toHaveBeenNthCalledWith(1, "");
|
||||
expect(setUserInput).toHaveBeenNthCalledWith(2, "steer this turn");
|
||||
expect(setAttachments).toHaveBeenNthCalledWith(1, []);
|
||||
expect(setAttachments).toHaveBeenNthCalledWith(2, [{ id: "img-1" }]);
|
||||
expect(setSendError).toHaveBeenLastCalledWith("connection lost after delivery");
|
||||
});
|
||||
|
||||
it("submits when empty submit is explicitly allowed", async () => {
|
||||
const queueMessage = vi.fn();
|
||||
const submitMessage = vi.fn(async () => {});
|
||||
|
||||
@@ -35,6 +35,14 @@ function makeDeps(
|
||||
}
|
||||
|
||||
describe("loadAppSettingsFromStorage", () => {
|
||||
it("preserves a persisted steer send behavior", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
"@paseo:app-settings": JSON.stringify({ sendBehavior: "steer" }),
|
||||
}),
|
||||
});
|
||||
expect((await loadAppSettingsFromStorage(deps)).sendBehavior).toBe("steer");
|
||||
});
|
||||
it("defaults theme to auto when storage is empty", async () => {
|
||||
const deps = makeDeps();
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isSyntaxThemeId, type SyntaxThemeId } from "@getpaseo/highlight";
|
||||
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import type { DesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||
import { parseAppLanguage, type AppLanguage } from "@/i18n/locales";
|
||||
@@ -21,7 +22,7 @@ export const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
export const APP_SETTINGS_QUERY_KEY = ["app-settings"];
|
||||
const LEGACY_SETTINGS_KEY = "@paseo:settings";
|
||||
|
||||
export type SendBehavior = "interrupt" | "queue";
|
||||
export type SendBehavior = ActiveTurnBehavior | "queue";
|
||||
export type ReleaseChannel = "stable" | "beta";
|
||||
export type ServiceUrlBehavior = "ask" | "in-app" | "external";
|
||||
export type WorkspaceTitleSource = "title" | "branch";
|
||||
@@ -92,7 +93,7 @@ const StoredAppSettingsSchema = z.strictObject({
|
||||
language: z
|
||||
.enum(["system", "ar", "en", "es", "fr", "ja", "ko", "pt-BR", "ru", "zh-CN"])
|
||||
.optional(),
|
||||
sendBehavior: z.enum(["interrupt", "queue"]).optional(),
|
||||
sendBehavior: z.enum(["interrupt", "steer", "queue"]).optional(),
|
||||
serviceUrlBehavior: z.enum(["ask", "in-app", "external"]).optional(),
|
||||
terminalScrollbackLines: z.union([z.number(), z.string()]).optional(),
|
||||
useLegacyTerminalRenderer: z.boolean().optional(),
|
||||
@@ -296,7 +297,11 @@ function pickEnumAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
|
||||
if (typeof stored.theme === "string" && VALID_THEMES.has(stored.theme)) {
|
||||
result.theme = stored.theme;
|
||||
}
|
||||
if (stored.sendBehavior === "interrupt" || stored.sendBehavior === "queue") {
|
||||
if (
|
||||
stored.sendBehavior === "interrupt" ||
|
||||
stored.sendBehavior === "steer" ||
|
||||
stored.sendBehavior === "queue"
|
||||
) {
|
||||
result.sendBehavior = stored.sendBehavior;
|
||||
}
|
||||
if (
|
||||
|
||||
@@ -97,6 +97,7 @@ export const ar: TranslationResources = {
|
||||
interruptAgent: "عامل المقاطعة",
|
||||
queueMessage: "رسالة قائمة الانتظار",
|
||||
sendAndInterrupt: "إرسال ومقاطعة",
|
||||
sendAndSteer: "إرسال وتوجيه",
|
||||
sendMessage: "أرسل رسالة",
|
||||
queue: "طابور",
|
||||
send: "يرسل",
|
||||
@@ -1826,10 +1827,13 @@ export const ar: TranslationResources = {
|
||||
descriptions: {
|
||||
interrupt:
|
||||
"عند تشغيل الوكيل، يوقف Enter التشغيل. Command/Ctrl+Enter يضيف إلى قائمة الانتظار.",
|
||||
steer:
|
||||
"عند تشغيل الوكيل، يوجّه Enter الجولة النشطة. يضيف Command/Ctrl+Enter إلى قائمة الانتظار.",
|
||||
queue: "عند تشغيل الوكيل، يضيف Enter إلى قائمة الانتظار. Command/Ctrl+Enter يرسل.",
|
||||
},
|
||||
options: {
|
||||
interrupt: "مقاطعة",
|
||||
steer: "توجيه",
|
||||
queue: "طابور",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -94,6 +94,7 @@ export const en = {
|
||||
interruptAgent: "Interrupt agent",
|
||||
queueMessage: "Queue message",
|
||||
sendAndInterrupt: "Send and interrupt",
|
||||
sendAndSteer: "Send and steer",
|
||||
sendMessage: "Send message",
|
||||
queue: "Queue",
|
||||
send: "Send",
|
||||
@@ -1890,10 +1891,13 @@ export const en = {
|
||||
label: "Default send",
|
||||
descriptions: {
|
||||
interrupt: "When the agent is running, Enter interrupts. Command/Ctrl+Enter queues.",
|
||||
steer:
|
||||
"When the agent is running, Enter steers the active turn. Command/Ctrl+Enter queues.",
|
||||
queue: "When the agent is running, Enter queues. Command/Ctrl+Enter submits.",
|
||||
},
|
||||
options: {
|
||||
interrupt: "Interrupt",
|
||||
steer: "Steer",
|
||||
queue: "Queue",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -97,6 +97,7 @@ export const es: TranslationResources = {
|
||||
interruptAgent: "agente de interrupción",
|
||||
queueMessage: "mensaje de cola",
|
||||
sendAndInterrupt: "Enviar e interrumpir",
|
||||
sendAndSteer: "Enviar y orientar",
|
||||
sendMessage: "enviar mensaje",
|
||||
queue: "Cola",
|
||||
send: "Enviar",
|
||||
@@ -1871,11 +1872,14 @@ export const es: TranslationResources = {
|
||||
descriptions: {
|
||||
interrupt:
|
||||
"Cuando el agente se está ejecutando, Enter interrumpe. Command/Ctrl+Enter pone en cola.",
|
||||
steer:
|
||||
"Cuando el agente se está ejecutando, Enter dirige el turno activo. Command/Ctrl+Enter pone en cola.",
|
||||
queue:
|
||||
"Cuando el agente se está ejecutando, Enter pone en cola. Command/Ctrl+Enter envía.",
|
||||
},
|
||||
options: {
|
||||
interrupt: "Interrumpir",
|
||||
steer: "Dirigir",
|
||||
queue: "Cola",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -99,6 +99,7 @@ export const fr: TranslationResources = {
|
||||
interruptAgent: "Agent d'interruption",
|
||||
queueMessage: "Message de file d'attente",
|
||||
sendAndInterrupt: "Envoyer et interrompre",
|
||||
sendAndSteer: "Envoyer et guider",
|
||||
sendMessage: "Envoyer un message",
|
||||
queue: "File d'attente",
|
||||
send: "Envoyer",
|
||||
@@ -1874,11 +1875,14 @@ export const fr: TranslationResources = {
|
||||
descriptions: {
|
||||
interrupt:
|
||||
"Lorsque l'agent est en cours d'exécution, Entrée interrompt. Commande/Ctrl+Entrée met en file d'attente.",
|
||||
steer:
|
||||
"Lorsque l’agent est en cours d’exécution, Entrée oriente le tour actif. Commande/Ctrl+Entrée met en file d’attente.",
|
||||
queue:
|
||||
"Lorsque l'agent est en cours d'exécution, Entrée met en file d'attente. Commande/Ctrl+Entrée envoie.",
|
||||
},
|
||||
options: {
|
||||
interrupt: "Interrompre",
|
||||
steer: "Orienter",
|
||||
queue: "File d'attente",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -97,6 +97,7 @@ export const ja: TranslationResources = {
|
||||
interruptAgent: "エージェントを中断",
|
||||
queueMessage: "メッセージをキューに追加",
|
||||
sendAndInterrupt: "送信して中断",
|
||||
sendAndSteer: "送信して指示を追加",
|
||||
sendMessage: "メッセージを送信",
|
||||
queue: "キュー",
|
||||
send: "送信",
|
||||
@@ -1841,10 +1842,13 @@ export const ja: TranslationResources = {
|
||||
label: "デフォルトの送信",
|
||||
descriptions: {
|
||||
interrupt: "エージェント実行中、Enterで中断します。Command/Ctrl+Enterでキューに追加。",
|
||||
steer:
|
||||
"エージェント実行中、Enterで現在のターンに指示を送ります。Command/Ctrl+Enterでキューに追加。",
|
||||
queue: "エージェント実行中、Enterでキューに追加します。Command/Ctrl+Enterで送信。",
|
||||
},
|
||||
options: {
|
||||
interrupt: "中断",
|
||||
steer: "指示",
|
||||
queue: "キュー",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -97,6 +97,7 @@ export const ko: TranslationResources = {
|
||||
interruptAgent: "에이전트 중단",
|
||||
queueMessage: "메시지 대기열에 추가",
|
||||
sendAndInterrupt: "보내고 중단",
|
||||
sendAndSteer: "보내고 지시 추가",
|
||||
sendMessage: "메시지 보내기",
|
||||
queue: "대기열",
|
||||
send: "보내기",
|
||||
@@ -1836,11 +1837,14 @@ export const ko: TranslationResources = {
|
||||
descriptions: {
|
||||
interrupt:
|
||||
"에이전트가 실행 중일 때 Enter는 중단합니다. Command/Ctrl+Enter는 대기열에 추가합니다.",
|
||||
steer:
|
||||
"에이전트가 실행 중일 때 Enter는 현재 턴에 지시합니다. Command/Ctrl+Enter는 대기열에 추가합니다.",
|
||||
queue:
|
||||
"에이전트가 실행 중일 때 Enter는 대기열에 추가합니다. Command/Ctrl+Enter는 제출합니다.",
|
||||
},
|
||||
options: {
|
||||
interrupt: "중단",
|
||||
steer: "지시",
|
||||
queue: "대기열",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -97,6 +97,7 @@ export const ptBR: TranslationResources = {
|
||||
interruptAgent: "Interromper agente",
|
||||
queueMessage: "Enfileirar mensagem",
|
||||
sendAndInterrupt: "Enviar e interromper",
|
||||
sendAndSteer: "Enviar e orientar",
|
||||
sendMessage: "Enviar mensagem",
|
||||
queue: "Fila",
|
||||
send: "Enviar",
|
||||
@@ -1856,10 +1857,13 @@ export const ptBR: TranslationResources = {
|
||||
descriptions: {
|
||||
interrupt:
|
||||
"Quando o agente está em execução, Enter interrompe. Command/Ctrl+Enter enfileira.",
|
||||
steer:
|
||||
"Quando o agente está em execução, Enter orienta o turno ativo. Command/Ctrl+Enter enfileira.",
|
||||
queue: "Quando o agente está em execução, Enter enfileira. Command/Ctrl+Enter envia.",
|
||||
},
|
||||
options: {
|
||||
interrupt: "Interromper",
|
||||
steer: "Orientar",
|
||||
queue: "Fila",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -97,6 +97,7 @@ export const ru: TranslationResources = {
|
||||
interruptAgent: "Агент прерываний",
|
||||
queueMessage: "Сообщение в очереди",
|
||||
sendAndInterrupt: "Отправить и прервать",
|
||||
sendAndSteer: "Отправить и направить",
|
||||
sendMessage: "Отправить сообщение",
|
||||
queue: "Очередь",
|
||||
send: "Отправлять",
|
||||
@@ -1860,10 +1861,13 @@ export const ru: TranslationResources = {
|
||||
label: "Отправка по умолчанию",
|
||||
descriptions: {
|
||||
interrupt: "Когда агент работает, Enter прерывает. Command/Ctrl+Enter ставит в очередь.",
|
||||
steer:
|
||||
"Когда агент работает, Enter направляет текущий ход. Command/Ctrl+Enter ставит в очередь.",
|
||||
queue: "Когда агент работает, Enter ставит в очередь. Command/Ctrl+Enter отправляет.",
|
||||
},
|
||||
options: {
|
||||
interrupt: "Прерывать",
|
||||
steer: "Направить",
|
||||
queue: "Очередь",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -97,6 +97,7 @@ export const zhCN: TranslationResources = {
|
||||
interruptAgent: "中断 Agent",
|
||||
queueMessage: "消息排队",
|
||||
sendAndInterrupt: "发送并中断",
|
||||
sendAndSteer: "发送并引导",
|
||||
sendMessage: "发送消息",
|
||||
queue: "排队",
|
||||
send: "发送",
|
||||
@@ -1804,10 +1805,12 @@ export const zhCN: TranslationResources = {
|
||||
label: "默认发送",
|
||||
descriptions: {
|
||||
interrupt: "Agent 运行时,Enter 会中断。Command/Ctrl+Enter 会排队。",
|
||||
steer: "Agent 运行时,Enter 会引导当前回合。Command/Ctrl+Enter 会排队。",
|
||||
queue: "Agent 运行时,Enter 会排队。Command/Ctrl+Enter 会提交。",
|
||||
},
|
||||
options: {
|
||||
interrupt: "中断",
|
||||
steer: "引导",
|
||||
queue: "排队",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -278,6 +278,43 @@ describe("ReplicaCache", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("restores canonical turn membership without downgrading tagged rows", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = new ReplicaCache(storage);
|
||||
writer.setHosts([SERVER_ID]);
|
||||
seedSession();
|
||||
const initial: StreamItem = {
|
||||
kind: "user_message",
|
||||
id: "initial",
|
||||
text: "initial",
|
||||
timestamp: new Date(1),
|
||||
turnId: "turn-1",
|
||||
};
|
||||
const hello: StreamItem = {
|
||||
kind: "user_message",
|
||||
id: "hello",
|
||||
text: "hello",
|
||||
timestamp: new Date(2),
|
||||
turnId: "turn-1",
|
||||
clientMessageId: "hello-client",
|
||||
messageId: "hello-client",
|
||||
};
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setAgentStreamTail(
|
||||
SERVER_ID,
|
||||
new Map([["agent-1", [initial, message("assistant", "done"), hello]]]),
|
||||
);
|
||||
await writer.flush();
|
||||
useSessionStore.getState().clearSession(SERVER_ID);
|
||||
const reader = new ReplicaCache(storage);
|
||||
reader.setHosts([SERVER_ID]);
|
||||
await reader.restore();
|
||||
const tail =
|
||||
useSessionStore.getState().sessions[SERVER_ID]?.agentStreamTail.get("agent-1") ?? [];
|
||||
expect(tail.find((item) => item.id === "hello")?.turnId).toBe("turn-1");
|
||||
});
|
||||
|
||||
it("restores tool calls inside an authoritative cached window", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = new ReplicaCache(storage);
|
||||
|
||||
@@ -31,6 +31,8 @@ const TimelinePositionSchema = z.strictObject({
|
||||
const TimelineItemBaseShape = {
|
||||
id: z.string(),
|
||||
timelineCursor: TimelinePositionSchema.optional(),
|
||||
// COMPAT(active-turn-membership): absent on caches written before turn membership.
|
||||
turnId: z.string().optional(),
|
||||
timestamp: IsoDateSchema,
|
||||
};
|
||||
|
||||
@@ -320,6 +322,7 @@ function timelineBase(item: StreamItem) {
|
||||
return {
|
||||
id: item.id,
|
||||
...(item.timelineCursor ? { timelineCursor: item.timelineCursor } : {}),
|
||||
...(item.turnId ? { turnId: item.turnId } : {}),
|
||||
timestamp: item.timestamp.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -394,6 +397,7 @@ function deserializeTimelineItem(item: StoredTimelineItem): StreamItem {
|
||||
const base = {
|
||||
id: item.id,
|
||||
...(item.timelineCursor ? { timelineCursor: item.timelineCursor } : {}),
|
||||
...(item.turnId ? { turnId: item.turnId } : {}),
|
||||
timestamp: new Date(item.timestamp),
|
||||
};
|
||||
switch (item.kind) {
|
||||
|
||||
@@ -249,6 +249,7 @@ function selectedSidebarItemStyle({ hovered }: PressableStateCallbackType & { ho
|
||||
function getSendBehaviorOptions(t: TFunction) {
|
||||
return [
|
||||
{ value: "interrupt" as const, label: t("settings.general.defaultSend.options.interrupt") },
|
||||
{ value: "steer" as const, label: t("settings.general.defaultSend.options.steer") },
|
||||
{ value: "queue" as const, label: t("settings.general.defaultSend.options.queue") },
|
||||
];
|
||||
}
|
||||
@@ -340,10 +341,7 @@ function GeneralSection({
|
||||
const { t, i18n } = useTranslation();
|
||||
const activeLocale = getActiveLocale(i18n.language);
|
||||
const sendBehaviorOptions = useMemo(() => getSendBehaviorOptions(t), [t]);
|
||||
const sendBehaviorDescriptionKey =
|
||||
settings.sendBehavior === "interrupt"
|
||||
? "settings.general.defaultSend.descriptions.interrupt"
|
||||
: "settings.general.defaultSend.descriptions.queue";
|
||||
const sendBehaviorDescriptionKey = `settings.general.defaultSend.descriptions.${settings.sendBehavior}`;
|
||||
const selectedLanguageOption = LANGUAGE_OPTIONS.find(
|
||||
(option) => option.value === settings.language,
|
||||
);
|
||||
|
||||
@@ -197,6 +197,32 @@ describe("deriveAgentStreamTurnLiveness", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("timeline turn membership compatibility", () => {
|
||||
it("hydrates tagged rows while retaining legacy rows without a turn ID", () => {
|
||||
const hydrated = hydrateStreamState([
|
||||
{
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
turnId: "turn-1",
|
||||
item: { type: "user_message", text: "prompt", clientMessageId: "prompt-id" },
|
||||
},
|
||||
timestamp: new Date(1000),
|
||||
},
|
||||
{
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: { type: "assistant_message", text: "legacy" },
|
||||
},
|
||||
timestamp: new Date(2000),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(hydrated.map((item) => item.turnId)).toEqual(["turn-1", undefined]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detached timeline windows", () => {
|
||||
it("does not apply or catch up live events while viewing an older window", () => {
|
||||
const currentTail = [makeAssistantItem("older window")];
|
||||
@@ -285,8 +311,17 @@ describe("processTimelineResponse", () => {
|
||||
text: "local prompt",
|
||||
timestamp: new Date(1000),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 1 },
|
||||
turnId: "turn-1",
|
||||
});
|
||||
const currentTail = [canonical, makeAssistantItem("existing tail", "existing-tail")];
|
||||
const hello = createUserMessage({
|
||||
id: "hello",
|
||||
clientMessageId: "hello-client",
|
||||
text: "hello",
|
||||
timestamp: new Date(1500),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 2 },
|
||||
turnId: "turn-1",
|
||||
});
|
||||
const currentTail = [canonical, makeAssistantItem("existing tail", "existing-tail"), hello];
|
||||
const currentHead = [makeAssistantItem("existing head", "existing-head")];
|
||||
|
||||
const result = processTimelineResponse({
|
||||
@@ -314,6 +349,7 @@ describe("processTimelineResponse", () => {
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(result.tail.find((item) => item.id === "hello")?.turnId).toBe("turn-1");
|
||||
|
||||
expect(result.commit).toBe("discard");
|
||||
expect(result.tail).toBe(currentTail);
|
||||
|
||||
@@ -126,6 +126,7 @@ interface TimelineResponseEntry {
|
||||
sourceSeqRanges?: TimelineSeqRange[];
|
||||
collapsed?: string[];
|
||||
provider: string;
|
||||
turnId?: string;
|
||||
item: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
}
|
||||
@@ -1269,6 +1270,7 @@ export function processTimelineResponse(
|
||||
type: "timeline",
|
||||
provider: entry.provider,
|
||||
item: entry.item,
|
||||
...(entry.turnId ? { turnId: entry.turnId } : {}),
|
||||
} as AgentStreamEventPayload,
|
||||
timestamp: new Date(entry.timestamp),
|
||||
}));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { startsNewTurn } from "@/agent-stream/turn-membership";
|
||||
|
||||
export interface TurnTiming {
|
||||
startedAt: Date;
|
||||
@@ -21,6 +22,7 @@ export function deriveStreamTurnTiming(params: {
|
||||
let currentUserAt: Date | null = null;
|
||||
let currentLastItemAt: Date | null = null;
|
||||
let currentAssistantIds: string[] = [];
|
||||
let previousItem: StreamItem | null = null;
|
||||
|
||||
const flushCompletedTurn = () => {
|
||||
if (!currentUserAt || !currentLastItemAt || currentAssistantIds.length === 0) {
|
||||
@@ -37,20 +39,21 @@ export function deriveStreamTurnTiming(params: {
|
||||
};
|
||||
|
||||
const visitItem = (item: StreamItem) => {
|
||||
if (item.kind === "user_message") {
|
||||
if (startsNewTurn(item, previousItem)) {
|
||||
flushCompletedTurn();
|
||||
currentUserAt = item.timestamp;
|
||||
currentUserAt = item.kind === "user_message" ? item.timestamp : null;
|
||||
currentLastItemAt = null;
|
||||
currentAssistantIds = [];
|
||||
return;
|
||||
}
|
||||
if (!currentUserAt) {
|
||||
previousItem = item;
|
||||
return;
|
||||
}
|
||||
currentLastItemAt = item.timestamp;
|
||||
if (item.kind === "assistant_message") {
|
||||
currentAssistantIds.push(item.id);
|
||||
}
|
||||
previousItem = item;
|
||||
};
|
||||
|
||||
for (const item of params.tail) {
|
||||
|
||||
@@ -22,6 +22,77 @@ import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display"
|
||||
type CanonicalToolStatus = "running" | "completed" | "failed" | "canceled";
|
||||
|
||||
describe("user message identity", () => {
|
||||
it("replaces provisional optimistic turn membership with canonical membership", () => {
|
||||
const optimistic = createUserMessage({
|
||||
clientMessageId: "hello-client",
|
||||
text: "hello",
|
||||
timestamp: new Date("2026-08-15T10:00:00Z"),
|
||||
turnId: "turn-a",
|
||||
});
|
||||
|
||||
const result = applyStreamEvent({
|
||||
tail: [optimistic],
|
||||
head: [],
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
turnId: "turn-b",
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: "hello",
|
||||
clientMessageId: "hello-client",
|
||||
messageId: "provider-hello",
|
||||
},
|
||||
},
|
||||
timestamp: new Date("2026-08-15T10:00:01Z"),
|
||||
});
|
||||
|
||||
expect(result.tail).toHaveLength(1);
|
||||
expect(result.tail[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: "user_message",
|
||||
clientMessageId: "hello-client",
|
||||
messageId: "provider-hello",
|
||||
turnId: "turn-b",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears provisional optimistic turn membership for a legacy canonical row", () => {
|
||||
const optimistic = createUserMessage({
|
||||
clientMessageId: "hello-client",
|
||||
text: "hello",
|
||||
timestamp: new Date("2026-08-15T10:00:00Z"),
|
||||
turnId: "turn-a",
|
||||
});
|
||||
|
||||
const result = applyStreamEvent({
|
||||
tail: [optimistic],
|
||||
head: [],
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: "hello",
|
||||
clientMessageId: "hello-client",
|
||||
messageId: "provider-hello",
|
||||
},
|
||||
},
|
||||
timestamp: new Date("2026-08-15T10:00:01Z"),
|
||||
});
|
||||
|
||||
expect(result.tail).toHaveLength(1);
|
||||
expect(result.tail[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: "user_message",
|
||||
clientMessageId: "hello-client",
|
||||
messageId: "provider-hello",
|
||||
}),
|
||||
);
|
||||
expect(result.tail[0]).not.toHaveProperty("turnId");
|
||||
});
|
||||
|
||||
it("adds provider identity without replacing local presentation", () => {
|
||||
const timestamp = new Date("2026-07-26T10:00:00.000Z");
|
||||
const local = createUserMessage({
|
||||
|
||||
@@ -88,6 +88,7 @@ export interface UserMessageItem {
|
||||
id: string;
|
||||
clientMessageId?: string;
|
||||
messageId?: string;
|
||||
turnId?: string;
|
||||
timelineCursor?: TimelinePosition;
|
||||
text: string;
|
||||
timestamp: Date;
|
||||
@@ -99,6 +100,7 @@ export interface UserMessageInput {
|
||||
id?: string;
|
||||
clientMessageId?: string;
|
||||
messageId?: string;
|
||||
turnId?: string;
|
||||
timelineCursor?: TimelinePosition;
|
||||
text: string;
|
||||
timestamp: Date;
|
||||
@@ -116,6 +118,7 @@ export function createUserMessage(input: UserMessageInput): UserMessageItem {
|
||||
id,
|
||||
...(input.clientMessageId ? { clientMessageId: input.clientMessageId } : {}),
|
||||
...(input.messageId ? { messageId: input.messageId } : {}),
|
||||
...(input.turnId ? { turnId: input.turnId } : {}),
|
||||
...(input.timelineCursor ? { timelineCursor: input.timelineCursor } : {}),
|
||||
text: input.text,
|
||||
timestamp: input.timestamp,
|
||||
@@ -676,6 +679,7 @@ export interface AssistantMessageItem {
|
||||
kind: "assistant_message";
|
||||
id: string;
|
||||
messageId?: string;
|
||||
turnId?: string;
|
||||
timelineCursor?: TimelinePosition;
|
||||
text: string;
|
||||
timestamp: Date;
|
||||
@@ -694,6 +698,7 @@ export interface ThoughtItem {
|
||||
kind: "thought";
|
||||
id: string;
|
||||
timelineCursor?: TimelinePosition;
|
||||
turnId?: string;
|
||||
text: string;
|
||||
timestamp: Date;
|
||||
status: ThoughtStatus;
|
||||
@@ -729,6 +734,7 @@ export interface ToolCallItem {
|
||||
kind: "tool_call";
|
||||
id: string;
|
||||
timelineCursor?: TimelinePosition;
|
||||
turnId?: string;
|
||||
timestamp: Date;
|
||||
payload: ToolCallPayload;
|
||||
}
|
||||
@@ -747,6 +753,7 @@ export interface ActivityLogItem {
|
||||
kind: "activity_log";
|
||||
id: string;
|
||||
timelineCursor?: TimelinePosition;
|
||||
turnId?: string;
|
||||
timestamp: Date;
|
||||
activityType: ActivityLogType;
|
||||
message: string;
|
||||
@@ -757,6 +764,7 @@ export interface CompactionItem {
|
||||
kind: "compaction";
|
||||
id: string;
|
||||
timelineCursor?: TimelinePosition;
|
||||
turnId?: string;
|
||||
timestamp: Date;
|
||||
status: "loading" | "completed";
|
||||
trigger?: "auto" | "manual";
|
||||
@@ -779,6 +787,7 @@ export interface TodoListItem {
|
||||
kind: "todo_list";
|
||||
id: string;
|
||||
timelineCursor?: TimelinePosition;
|
||||
turnId?: string;
|
||||
timestamp: Date;
|
||||
provider: AgentProvider;
|
||||
items: TodoEntry[];
|
||||
@@ -839,6 +848,7 @@ function appendUserMessage(
|
||||
messageId?: string,
|
||||
clientMessageId?: string,
|
||||
timelineCursor?: TimelinePosition,
|
||||
turnId?: string,
|
||||
): StreamItem[] {
|
||||
const { chunk, hasContent } = normalizeChunk(text);
|
||||
if (!hasContent) {
|
||||
@@ -851,6 +861,7 @@ function appendUserMessage(
|
||||
clientMessageId,
|
||||
messageId,
|
||||
timelineCursor,
|
||||
turnId,
|
||||
text: chunk,
|
||||
timestamp,
|
||||
});
|
||||
@@ -1414,6 +1425,7 @@ function reduceTimelineEvent(
|
||||
item.messageId,
|
||||
item.clientMessageId,
|
||||
timelineCursor,
|
||||
event.turnId,
|
||||
),
|
||||
);
|
||||
case "assistant_message":
|
||||
@@ -1478,13 +1490,16 @@ export function reduceStreamUpdate(
|
||||
const source = options?.source ?? "live";
|
||||
switch (event.type) {
|
||||
case "timeline":
|
||||
return reduceTimelineEvent(
|
||||
state,
|
||||
return applyTimelineTurnId(
|
||||
reduceTimelineEvent(
|
||||
state,
|
||||
event,
|
||||
timestamp,
|
||||
source,
|
||||
options?.reservedItemIds,
|
||||
options?.timelineCursor,
|
||||
),
|
||||
event,
|
||||
timestamp,
|
||||
source,
|
||||
options?.reservedItemIds,
|
||||
options?.timelineCursor,
|
||||
);
|
||||
case "thread_started":
|
||||
case "turn_started":
|
||||
@@ -1500,6 +1515,51 @@ export function reduceStreamUpdate(
|
||||
}
|
||||
}
|
||||
|
||||
function applyTimelineTurnId(
|
||||
items: StreamItem[],
|
||||
event: Extract<AgentStreamEventPayload, { type: "timeline" }>,
|
||||
): StreamItem[] {
|
||||
const clientMessageId =
|
||||
event.item.type === "user_message" ? event.item.clientMessageId : undefined;
|
||||
if (clientMessageId) {
|
||||
return reconcileCanonicalUserTurnMembership(items, clientMessageId, event.turnId);
|
||||
}
|
||||
|
||||
if (!event.turnId || items.length === 0) return items;
|
||||
const index = items.length - 1;
|
||||
const last = items[index];
|
||||
if (!last || last.turnId === event.turnId) return items;
|
||||
return [
|
||||
...items.slice(0, index),
|
||||
{ ...last, turnId: event.turnId } as StreamItem,
|
||||
...items.slice(index + 1),
|
||||
];
|
||||
}
|
||||
|
||||
function reconcileCanonicalUserTurnMembership(
|
||||
items: StreamItem[],
|
||||
clientMessageId: string,
|
||||
turnId: string | undefined,
|
||||
): StreamItem[] {
|
||||
const index = items.findIndex(
|
||||
(item) => item.kind === "user_message" && item.clientMessageId === clientMessageId,
|
||||
);
|
||||
const matched = items[index];
|
||||
if (!matched || matched.kind !== "user_message" || matched.turnId === turnId) {
|
||||
return items;
|
||||
}
|
||||
|
||||
// A canonical user row is authoritative for membership. This replaces a
|
||||
// provisional optimistic turn and clears it for daemons that do not emit IDs.
|
||||
const next = turnId
|
||||
? { ...matched, turnId }
|
||||
: (() => {
|
||||
const { turnId: _, ...withoutTurnId } = matched;
|
||||
return withoutTurnId;
|
||||
})();
|
||||
return [...items.slice(0, index), next, ...items.slice(index + 1)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate stream state from a batch of AgentManager stream events
|
||||
*/
|
||||
@@ -1790,6 +1850,7 @@ function applyCanonicalUserMessageEvent(params: {
|
||||
createUniqueTimelineId([...tail, ...head], "user", normalized.chunk.trim(), timestamp),
|
||||
messageId: event.item.messageId,
|
||||
clientMessageId: event.item.clientMessageId,
|
||||
turnId: event.turnId,
|
||||
timelineCursor,
|
||||
text: normalized.chunk,
|
||||
timestamp,
|
||||
@@ -1802,11 +1863,25 @@ function applyCanonicalUserMessageEvent(params: {
|
||||
insert: normalized.hasContent ? "head" : "none",
|
||||
presentation: "existing",
|
||||
});
|
||||
const reconciledTail = canonical.clientMessageId
|
||||
? reconcileCanonicalUserTurnMembership(
|
||||
reconciled.tail,
|
||||
canonical.clientMessageId,
|
||||
event.turnId,
|
||||
)
|
||||
: reconciled.tail;
|
||||
const reconciledHead = canonical.clientMessageId
|
||||
? reconcileCanonicalUserTurnMembership(
|
||||
reconciled.head,
|
||||
canonical.clientMessageId,
|
||||
event.turnId,
|
||||
)
|
||||
: reconciled.head;
|
||||
return {
|
||||
tail: reconciled.tail,
|
||||
head: reconciled.head,
|
||||
changedTail: reconciled.changedTail,
|
||||
changedHead: reconciled.changedHead,
|
||||
tail: reconciledTail,
|
||||
head: reconciledHead,
|
||||
changedTail: reconciled.changedTail || reconciledTail !== reconciled.tail,
|
||||
changedHead: reconciled.changedHead || reconciledHead !== reconciled.head,
|
||||
acknowledgedClientMessageIds:
|
||||
reconciled.location?.matched && reconciled.location.message.clientMessageId
|
||||
? [reconciled.location.message.clientMessageId]
|
||||
@@ -1814,10 +1889,17 @@ function applyCanonicalUserMessageEvent(params: {
|
||||
};
|
||||
}
|
||||
const reconciled = placeCanonicalUserMessageAtTail(flushedTail, canonical, normalized.hasContent);
|
||||
const reconciledTail = canonical.clientMessageId
|
||||
? reconcileCanonicalUserTurnMembership(
|
||||
reconciled.items,
|
||||
canonical.clientMessageId,
|
||||
event.turnId,
|
||||
)
|
||||
: reconciled.items;
|
||||
return {
|
||||
tail: reconciled.items,
|
||||
tail: reconciledTail,
|
||||
head: flushedHead,
|
||||
changedTail: flushedTail !== tail || reconciled.items !== flushedTail,
|
||||
changedTail: flushedTail !== tail || reconciledTail !== flushedTail,
|
||||
changedHead: flushedHead !== head,
|
||||
acknowledgedClientMessageIds:
|
||||
reconciled.matched && reconciled.message.clientMessageId
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ShutdownRequestedStatusPayloadSchema,
|
||||
DaemonUpdateResponseSchema,
|
||||
SessionInboundMessageSchema,
|
||||
type ActiveTurnBehavior,
|
||||
type ServerInfoStatusPayload,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { validateWSOutboundMessage } from "@getpaseo/protocol/validation/ws-outbound";
|
||||
@@ -336,6 +337,7 @@ export interface DaemonClientTrace {
|
||||
|
||||
export interface SendMessageOptions {
|
||||
messageId?: string;
|
||||
activeTurnBehavior?: ActiveTurnBehavior;
|
||||
images?: Array<{ data: string; mimeType: string }>;
|
||||
attachments?: SendAgentMessageRequest["attachments"];
|
||||
}
|
||||
@@ -2947,6 +2949,7 @@ export class DaemonClient {
|
||||
agentId,
|
||||
text,
|
||||
...(messageId ? { messageId } : {}),
|
||||
...(options?.activeTurnBehavior ? { activeTurnBehavior: options.activeTurnBehavior } : {}),
|
||||
...(options?.images ? { images: options.images } : {}),
|
||||
...(options?.attachments ? { attachments: options.attachments } : {}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
AgentStreamEventPayloadSchema,
|
||||
AgentTimelineEntryPayloadSchema,
|
||||
SendAgentMessageRequestSchema,
|
||||
} from "./messages";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
describe("send_agent_message_request active-turn behavior", () => {
|
||||
it("accepts an optional steer intent while retaining interrupt compatibility", () => {
|
||||
expect(
|
||||
SendAgentMessageRequestSchema.parse({
|
||||
type: "send_agent_message_request",
|
||||
requestId: "request-1",
|
||||
agentId: "agent-1",
|
||||
text: "Follow this instruction",
|
||||
activeTurnBehavior: "steer",
|
||||
}).activeTurnBehavior,
|
||||
).toBe("steer");
|
||||
|
||||
expect(
|
||||
SendAgentMessageRequestSchema.parse({
|
||||
type: "send_agent_message_request",
|
||||
requestId: "request-2",
|
||||
agentId: "agent-1",
|
||||
text: "Keep the old behavior",
|
||||
}).activeTurnBehavior,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("canonical timeline turn ID compatibility", () => {
|
||||
it("accepts both new turn-tagged wire rows and legacy rows without IDs", () => {
|
||||
const entry = {
|
||||
provider: "codex" as const,
|
||||
item: { type: "assistant_message" as const, text: "done" },
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
seqStart: 1,
|
||||
seqEnd: 1,
|
||||
sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }],
|
||||
collapsed: [],
|
||||
};
|
||||
expect(AgentTimelineEntryPayloadSchema.parse(entry).turnId).toBeUndefined();
|
||||
expect(AgentTimelineEntryPayloadSchema.parse({ ...entry, turnId: "turn-1" }).turnId).toBe(
|
||||
"turn-1",
|
||||
);
|
||||
|
||||
const timeline = {
|
||||
type: "timeline" as const,
|
||||
provider: "codex" as const,
|
||||
item: { type: "assistant_message" as const, text: "done" },
|
||||
};
|
||||
expect(AgentStreamEventPayloadSchema.parse(timeline).turnId).toBeUndefined();
|
||||
expect(AgentStreamEventPayloadSchema.parse({ ...timeline, turnId: "turn-1" }).turnId).toBe(
|
||||
"turn-1",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy daemon send request schema compatibility", () => {
|
||||
const LegacySendAgentMessageRequestSchema = z.object({
|
||||
type: z.literal("send_agent_message_request"),
|
||||
requestId: z.string(),
|
||||
agentId: z.string(),
|
||||
text: z.string(),
|
||||
messageId: z.string().optional(),
|
||||
});
|
||||
|
||||
it("ignores a new client's active-turn intent and retains legacy interrupt dispatch", () => {
|
||||
const newClientRequest = SendAgentMessageRequestSchema.parse({
|
||||
type: "send_agent_message_request",
|
||||
requestId: "request-legacy",
|
||||
agentId: "agent-1",
|
||||
text: "replace the turn",
|
||||
activeTurnBehavior: "steer",
|
||||
});
|
||||
const legacyRequest = LegacySendAgentMessageRequestSchema.parse(newClientRequest);
|
||||
|
||||
expect(legacyRequest).toEqual({
|
||||
type: "send_agent_message_request",
|
||||
requestId: "request-legacy",
|
||||
agentId: "agent-1",
|
||||
text: "replace the turn",
|
||||
});
|
||||
expect("activeTurnBehavior" in legacyRequest).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -748,6 +748,7 @@ export const AgentStreamEventPayloadSchema = z.discriminatedUnion("type", [
|
||||
type: z.literal("timeline"),
|
||||
provider: AgentProviderSchema,
|
||||
item: AgentTimelineItemPayloadSchema,
|
||||
turnId: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("permission_requested"),
|
||||
@@ -1138,11 +1139,15 @@ const ImageAttachmentSchema = z.object({
|
||||
mimeType: z.string(), // e.g., "image/jpeg", "image/png"
|
||||
});
|
||||
|
||||
export const ActiveTurnBehaviorSchema = z.enum(["interrupt", "steer"]);
|
||||
export type ActiveTurnBehavior = z.infer<typeof ActiveTurnBehaviorSchema>;
|
||||
|
||||
export const SendAgentMessageSchema = z.object({
|
||||
type: z.literal("send_agent_message"),
|
||||
agentId: z.string(),
|
||||
text: z.string(),
|
||||
messageId: z.string().optional(), // Client-provided ID for deduplication
|
||||
activeTurnBehavior: ActiveTurnBehaviorSchema.optional(),
|
||||
images: z.array(ImageAttachmentSchema).optional(),
|
||||
attachments: AgentAttachmentsSchema,
|
||||
});
|
||||
@@ -1295,6 +1300,7 @@ export const SendAgentMessageRequestSchema = z.object({
|
||||
agentId: z.string(),
|
||||
text: z.string(),
|
||||
messageId: z.string().optional(), // Client-provided ID for deduplication
|
||||
activeTurnBehavior: ActiveTurnBehaviorSchema.optional(),
|
||||
images: z.array(ImageAttachmentSchema).optional(),
|
||||
attachments: AgentAttachmentsSchema,
|
||||
});
|
||||
@@ -4035,6 +4041,7 @@ const AgentTimelineSeqRangeSchema = z.object({
|
||||
export const AgentTimelineEntryPayloadSchema = z.object({
|
||||
provider: AgentProviderSchema,
|
||||
item: AgentTimelineItemPayloadSchema,
|
||||
turnId: z.string().optional(),
|
||||
timestamp: z.string(),
|
||||
seqStart: z.number().int().nonnegative(),
|
||||
seqEnd: z.number().int().nonnegative(),
|
||||
|
||||
@@ -438,7 +438,7 @@ describe("target coalesced behavior", () => {
|
||||
});
|
||||
session.setHistory([timelineEvent(toolCall({ status: "completed", output }))]);
|
||||
|
||||
await harness.manager.hydrateTimelineFromProvider(agentId);
|
||||
await harness.manager.hydrateTimelineFromProvider(agentId, { force: true });
|
||||
|
||||
expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([
|
||||
expectedItem,
|
||||
@@ -1209,7 +1209,7 @@ describe("target coalesced behavior", () => {
|
||||
const { agentId, session } = await createManagedSession(harness);
|
||||
session.setHistory([assistant("a1"), assistant("a2"), reasoning("r1"), reasoning("r2")]);
|
||||
|
||||
await harness.manager.hydrateTimelineFromProvider(agentId);
|
||||
await harness.manager.hydrateTimelineFromProvider(agentId, { force: true });
|
||||
|
||||
const rows = await harness.manager.getTimelineRows(agentId);
|
||||
expect(getTimelineItems(rows)).toEqual([
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -38,6 +38,7 @@ import {
|
||||
type AgentRunResult,
|
||||
type AgentSession,
|
||||
type AgentSessionConfig,
|
||||
type SteerResult,
|
||||
type AgentStreamEvent,
|
||||
type AgentTimelineItem,
|
||||
type AgentUsage,
|
||||
@@ -59,6 +60,8 @@ import type {
|
||||
AgentTimelineRow,
|
||||
AgentTimelineStore,
|
||||
} from "./agent-timeline-store-types.js";
|
||||
import { DurableTimelineBuffer, type DurableTimelineTimers } from "./durable-timeline-buffer.js";
|
||||
import { reconcileProviderHistory } from "./history-reconciliation.js";
|
||||
import {
|
||||
AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS,
|
||||
AgentStreamCoalescer,
|
||||
@@ -277,10 +280,20 @@ export interface AgentManagerOptions {
|
||||
paseoToolCatalogFactory?: PaseoToolCatalogFactory;
|
||||
appendSystemPrompt?: string;
|
||||
agentStreamCoalesceWindowMs?: number;
|
||||
durableTimelineCoalesceWindowMs?: number;
|
||||
durableTimelineTimers?: DurableTimelineTimers;
|
||||
rescueTimeouts?: AgentManagerRescueTimeouts;
|
||||
beforeSteerUnavailableFallback?: (input: {
|
||||
agentId: string;
|
||||
expectedTurnId: string;
|
||||
}) => Promise<void>;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export type ActiveTurnSteerDispatchResult =
|
||||
| { status: "inactive" | "steered" }
|
||||
| { status: "replaced"; iterator: AsyncGenerator<AgentStreamEvent> };
|
||||
|
||||
export interface WaitForAgentOptions {
|
||||
signal?: AbortSignal;
|
||||
waitForActive?: boolean;
|
||||
@@ -452,6 +465,12 @@ function attachManagedTurnIdentity(
|
||||
const turnId = agent.activeForegroundTurnId ?? agent.activeTurnId ?? undefined;
|
||||
return turnId ? { event: { ...event, turnId }, turnId } : { event, turnId };
|
||||
}
|
||||
case "timeline": {
|
||||
// Live provider items belong to the foreground turn that owns their dispatch.
|
||||
// History deliberately keeps absent IDs so old persisted rows retain legacy grouping.
|
||||
const turnId = agent.activeForegroundTurnId ?? agent.activeTurnId ?? undefined;
|
||||
return turnId ? { event: { ...event, turnId }, turnId } : { event, turnId };
|
||||
}
|
||||
default:
|
||||
return { event, turnId: undefined };
|
||||
}
|
||||
@@ -496,6 +515,10 @@ interface SubscriptionRecord {
|
||||
agentId: string | null;
|
||||
}
|
||||
|
||||
interface SteerEventBarrier {
|
||||
events: AgentStreamEvent[];
|
||||
}
|
||||
|
||||
const BUSY_STATUSES: Set<AgentLifecycleStatus> = new Set(["initializing", "running"]);
|
||||
const AgentIdSchema = z.guid();
|
||||
|
||||
@@ -645,11 +668,14 @@ export class AgentManager {
|
||||
private readonly providerSubagents = new ProviderSubagentStore();
|
||||
private readonly agentsAwaitingInitialSnapshotPersist = new Set<string>();
|
||||
private readonly sessionEventTails = new Map<string, Promise<void>>();
|
||||
private readonly steerEventBarriers = new Map<string, SteerEventBarrier>();
|
||||
private readonly foregroundMutationTails = new Map<string, Promise<void>>();
|
||||
private readonly runs = new AgentRunState();
|
||||
private readonly subscribers = new Set<SubscriptionRecord>();
|
||||
private readonly idFactory: () => string;
|
||||
private readonly registry?: AgentStorage;
|
||||
private readonly durableTimelineStore?: AgentTimelineStore;
|
||||
private readonly durableTimelineBuffer?: DurableTimelineBuffer;
|
||||
private readonly previousStatuses = new Map<string, AgentLifecycleStatus>();
|
||||
private readonly backgroundTasks = new Set<Promise<void>>();
|
||||
private readonly agentRegistrationTasks = new Set<Promise<void>>();
|
||||
@@ -666,6 +692,7 @@ export class AgentManager {
|
||||
private onWorkspaceStateMayHaveChanged?: (params: { cwd: string }) => void;
|
||||
private logger: Logger;
|
||||
private readonly rescueTimeouts: Required<AgentManagerRescueTimeouts>;
|
||||
private readonly beforeSteerUnavailableFallback?: AgentManagerOptions["beforeSteerUnavailableFallback"];
|
||||
private acceptingAgentRegistrations = true;
|
||||
|
||||
constructor(options: AgentManagerOptions) {
|
||||
@@ -679,12 +706,14 @@ export class AgentManager {
|
||||
this.configurePaseoTools(options);
|
||||
this.appendSystemPrompt = options.appendSystemPrompt ?? "";
|
||||
this.logger = options.logger.child({ module: "agent", component: "agent-manager" });
|
||||
this.durableTimelineBuffer = this.createDurableTimelineBuffer(options);
|
||||
this.rescueTimeouts = {
|
||||
reloadSessionCloseMs:
|
||||
options.rescueTimeouts?.reloadSessionCloseMs ?? RELOAD_SESSION_CLOSE_TIMEOUT_MS,
|
||||
interruptSessionMs:
|
||||
options.rescueTimeouts?.interruptSessionMs ?? INTERRUPT_SESSION_TIMEOUT_MS,
|
||||
};
|
||||
this.beforeSteerUnavailableFallback = options.beforeSteerUnavailableFallback;
|
||||
this.agentStreamCoalescer = new AgentStreamCoalescer({
|
||||
windowMs: options.agentStreamCoalesceWindowMs ?? AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS,
|
||||
timers: { setTimeout, clearTimeout },
|
||||
@@ -699,6 +728,20 @@ export class AgentManager {
|
||||
});
|
||||
}
|
||||
|
||||
private createDurableTimelineBuffer(
|
||||
options: AgentManagerOptions,
|
||||
): DurableTimelineBuffer | undefined {
|
||||
if (!this.durableTimelineStore) return undefined;
|
||||
return new DurableTimelineBuffer(
|
||||
this.durableTimelineStore,
|
||||
options.durableTimelineCoalesceWindowMs ?? 25,
|
||||
options.durableTimelineTimers ?? { setTimeout, clearTimeout },
|
||||
(agentId, error) => {
|
||||
this.logger.error({ err: error, agentId }, "Failed to persist queued timeline rows");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private configurePaseoTools(options: AgentManagerOptions): void {
|
||||
this.paseoToolsEnabled = options.paseoToolsEnabled ?? true;
|
||||
this.paseoToolCatalogFactory = options.paseoToolCatalogFactory ?? null;
|
||||
@@ -1134,6 +1177,7 @@ export class AgentManager {
|
||||
initialTitle: options.initialTitle,
|
||||
workspaceId: options.workspaceId,
|
||||
owner: options.owner,
|
||||
historyPrimed: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2199,6 +2243,7 @@ export class AgentManager {
|
||||
if (options?.clientMessageId) {
|
||||
this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, {
|
||||
messageId: options.clientMessageId,
|
||||
turnId,
|
||||
providerMessageId:
|
||||
stagedSubmittedPromptEcho?.item.type === "user_message"
|
||||
? stagedSubmittedPromptEcho.item.messageId
|
||||
@@ -2347,6 +2392,169 @@ export class AgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
async steerAgentRun(
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
options?: AgentRunOptions,
|
||||
): Promise<SteerResult> {
|
||||
const agent = this.requireSessionAgent(agentId);
|
||||
const expectedTurnId = agent.activeForegroundTurnId;
|
||||
if (!expectedTurnId || !this.runs.hasRun(agentId) || !agent.session.steerActiveTurn) {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
const result = await this.runSteerAdmission(agent, expectedTurnId, async () => {
|
||||
const admission = await agent.session.steerActiveTurn!(prompt, {
|
||||
...options,
|
||||
expectedTurnId,
|
||||
});
|
||||
if (admission.status === "accepted") {
|
||||
await this.recordAcceptedSteer(agent, prompt, options?.clientMessageId, expectedTurnId);
|
||||
}
|
||||
return admission;
|
||||
});
|
||||
// An unavailable answer is only safe to fall back from while this admission
|
||||
// still owns the foreground. Never let an A admission replace a later B.
|
||||
if (result.status === "unavailable" && agent.activeForegroundTurnId !== expectedTurnId) {
|
||||
throw new Error("Active turn changed before steering could be delivered");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async steerOrReplaceActiveTurn(
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
options?: AgentRunOptions,
|
||||
): Promise<ActiveTurnSteerDispatchResult> {
|
||||
const agent = this.requireSessionAgent(agentId);
|
||||
const expectedTurnId = agent.activeForegroundTurnId;
|
||||
if (!expectedTurnId || !this.runs.hasRun(agentId)) {
|
||||
return { status: "inactive" };
|
||||
}
|
||||
|
||||
const result = agent.session.steerActiveTurn
|
||||
? await this.runSteerAdmission(agent, expectedTurnId, async () => {
|
||||
const admission = await agent.session.steerActiveTurn!(prompt, {
|
||||
...options,
|
||||
expectedTurnId,
|
||||
});
|
||||
if (admission.status === "accepted") {
|
||||
await this.recordAcceptedSteer(agent, prompt, options?.clientMessageId, expectedTurnId);
|
||||
}
|
||||
return admission;
|
||||
})
|
||||
: { status: "unavailable" as const };
|
||||
if (result.status === "accepted") {
|
||||
return { status: "steered" };
|
||||
}
|
||||
|
||||
await this.beforeSteerUnavailableFallback?.({ agentId, expectedTurnId });
|
||||
this.assertSteerAdmissionOwnsForeground(agent, expectedTurnId);
|
||||
return {
|
||||
status: "replaced",
|
||||
iterator: await this.replaceAdmittedForegroundTurn(agent, expectedTurnId, prompt, options),
|
||||
};
|
||||
}
|
||||
|
||||
private assertSteerAdmissionOwnsForeground(
|
||||
agent: ActiveManagedAgent,
|
||||
expectedTurnId: string,
|
||||
): void {
|
||||
if (agent.activeForegroundTurnId !== expectedTurnId || !this.runs.hasRun(agent.id)) {
|
||||
throw new Error("Active turn changed before steering could be delivered");
|
||||
}
|
||||
}
|
||||
|
||||
private async runSteerAdmission<T>(
|
||||
agent: ActiveManagedAgent,
|
||||
expectedTurnId: string,
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
return this.runForegroundMutation(agent.id, async () => {
|
||||
await this.drainSessionEvents(agent.id);
|
||||
this.agentStreamCoalescer.flushFor(agent.id);
|
||||
this.assertSteerAdmissionOwnsForeground(agent, expectedTurnId);
|
||||
const barrier: SteerEventBarrier = { events: [] };
|
||||
this.steerEventBarriers.set(agent.id, barrier);
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
if (this.steerEventBarriers.get(agent.id) === barrier) {
|
||||
this.steerEventBarriers.delete(agent.id);
|
||||
}
|
||||
for (const event of barrier.events) {
|
||||
this.enqueueSessionEvent(agent.id, event);
|
||||
}
|
||||
await this.drainSessionEvents(agent.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async runForegroundMutation<T>(agentId: string, operation: () => Promise<T>): Promise<T> {
|
||||
const previous = this.foregroundMutationTails.get(agentId) ?? Promise.resolve();
|
||||
const run = previous.catch(() => undefined).then(operation);
|
||||
const tail = run.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
this.foregroundMutationTails.set(agentId, tail);
|
||||
try {
|
||||
return await run;
|
||||
} finally {
|
||||
if (this.foregroundMutationTails.get(agentId) === tail) {
|
||||
this.foregroundMutationTails.delete(agentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async replaceAdmittedForegroundTurn(
|
||||
agent: ActiveManagedAgent,
|
||||
expectedTurnId: string,
|
||||
prompt: AgentPromptInput,
|
||||
options?: AgentRunOptions,
|
||||
): Promise<AsyncGenerator<AgentStreamEvent>> {
|
||||
this.assertSteerAdmissionOwnsForeground(agent, expectedTurnId);
|
||||
agent.pendingReplacement = true;
|
||||
agent.lifecycle = "running";
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
|
||||
try {
|
||||
await this.cancelAgentRunBefore(agent.id, "replace");
|
||||
return this.streamAgent(agent.id, prompt, options);
|
||||
} catch (error) {
|
||||
const latest = this.agents.get(agent.id);
|
||||
if (latest) {
|
||||
latest.pendingReplacement = false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async recordAcceptedSteer(
|
||||
agent: ActiveManagedAgent,
|
||||
prompt: AgentPromptInput,
|
||||
clientMessageId: string | undefined,
|
||||
expectedTurnId: string,
|
||||
): Promise<void> {
|
||||
if (!clientMessageId) {
|
||||
return;
|
||||
}
|
||||
this.recordSubmittedPrompt(agent, prompt, clientMessageId, {
|
||||
messageId: clientMessageId,
|
||||
turnId: expectedTurnId,
|
||||
});
|
||||
if (this.durableTimelineBuffer) {
|
||||
try {
|
||||
await this.durableTimelineBuffer.flush(agent.id);
|
||||
} catch (error) {
|
||||
throw new Error("Steering was accepted but its canonical message could not be persisted", {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
this.emitState(agent);
|
||||
}
|
||||
|
||||
async waitForAgentRunStart(agentId: string, options?: WaitForAgentStartOptions): Promise<void> {
|
||||
const snapshot = this.getAgent(agentId);
|
||||
if (!snapshot) {
|
||||
@@ -2489,6 +2697,10 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
async cancelAgentRun(agentId: string): Promise<AgentRunCancellationResult> {
|
||||
return this.runForegroundMutation(agentId, () => this.cancelAgentRunNow(agentId));
|
||||
}
|
||||
|
||||
private async cancelAgentRunNow(agentId: string): Promise<AgentRunCancellationResult> {
|
||||
const agent = this.requireSessionAgent(agentId);
|
||||
const run =
|
||||
this.runs.getRun(agentId) ??
|
||||
@@ -2651,6 +2863,7 @@ export class AgentManager {
|
||||
if (!this.durableTimelineStore) {
|
||||
return;
|
||||
}
|
||||
await this.durableTimelineBuffer?.discard(agentId);
|
||||
await this.durableTimelineStore.deleteAgent(agentId);
|
||||
}
|
||||
|
||||
@@ -2935,7 +3148,7 @@ export class AgentManager {
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
const { durableTimelineHasRows } = await this.initializeAgentTimelineForRegister({
|
||||
const { durableHistoryComplete } = await this.initializeAgentTimelineForRegister({
|
||||
agentId: resolvedAgentId,
|
||||
now,
|
||||
options,
|
||||
@@ -2946,10 +3159,12 @@ export class AgentManager {
|
||||
session,
|
||||
config,
|
||||
now,
|
||||
durableTimelineHasRows,
|
||||
options,
|
||||
});
|
||||
|
||||
if (managed.historyPrimed && !durableHistoryComplete) {
|
||||
await this.commitCompleteHistorySnapshot(resolvedAgentId);
|
||||
}
|
||||
this.assertAcceptingAgentRegistrations();
|
||||
this.agents.set(resolvedAgentId, managed);
|
||||
registered = true;
|
||||
@@ -3029,7 +3244,7 @@ export class AgentManager {
|
||||
updatedAt?: Date;
|
||||
}
|
||||
| undefined;
|
||||
}): Promise<{ durableTimelineHasRows: boolean }> {
|
||||
}): Promise<{ durableHistoryComplete: boolean }> {
|
||||
const { agentId, now, options } = params;
|
||||
const timelineAlreadyPrimed = this.timelineStore.has(agentId);
|
||||
const explicitTimelineSeed = buildExplicitTimelineSeedForRegister(now, options);
|
||||
@@ -3040,9 +3255,8 @@ export class AgentManager {
|
||||
const durableTimelineSeed = shouldSeedFromDurable
|
||||
? await this.loadCommittedTimelineSeed(agentId, now)
|
||||
: null;
|
||||
const durableTimelineHasRows =
|
||||
timelineAlreadyPrimed ||
|
||||
(durableTimelineSeed != null && (durableTimelineSeed.nextSeq ?? 1) > 1);
|
||||
const durableHistoryComplete =
|
||||
timelineAlreadyPrimed || durableTimelineSeed?.historyComplete === true;
|
||||
const timelineSeed = explicitTimelineSeed ?? durableTimelineSeed;
|
||||
if (timelineSeed || !this.timelineStore.has(agentId)) {
|
||||
this.timelineStore.initialize(agentId, timelineSeed ?? { timestamp: now.toISOString() });
|
||||
@@ -3050,7 +3264,7 @@ export class AgentManager {
|
||||
if (options?.timelineRows?.length) {
|
||||
this.enqueueDurableTimelineBulkInsert(agentId, options.timelineRows);
|
||||
}
|
||||
return { durableTimelineHasRows };
|
||||
return { durableHistoryComplete };
|
||||
}
|
||||
|
||||
private buildManagedAgentForRegister(params: {
|
||||
@@ -3058,7 +3272,6 @@ export class AgentManager {
|
||||
session: AgentSession;
|
||||
config: AgentSessionConfig;
|
||||
now: Date;
|
||||
durableTimelineHasRows: boolean;
|
||||
options:
|
||||
| {
|
||||
createdAt?: Date;
|
||||
@@ -3075,7 +3288,7 @@ export class AgentManager {
|
||||
}
|
||||
| undefined;
|
||||
}): ActiveManagedAgent {
|
||||
const { resolvedAgentId, session, config, now, durableTimelineHasRows, options } = params;
|
||||
const { resolvedAgentId, session, config, now, options } = params;
|
||||
return {
|
||||
id: resolvedAgentId,
|
||||
provider: config.provider,
|
||||
@@ -3105,7 +3318,7 @@ export class AgentManager {
|
||||
options?.persistence ?? session.describePersistence(),
|
||||
config.cwd,
|
||||
),
|
||||
historyPrimed: options?.historyPrimed ?? durableTimelineHasRows,
|
||||
historyPrimed: options?.historyPrimed ?? false,
|
||||
lastUserMessageAt: options?.lastUserMessageAt ?? null,
|
||||
lastUsage: options?.lastUsage,
|
||||
lastError: options?.lastError,
|
||||
@@ -3118,14 +3331,18 @@ export class AgentManager {
|
||||
private async loadCommittedTimelineSeed(
|
||||
agentId: string,
|
||||
now: Date,
|
||||
): Promise<SeedAgentTimelineOptions> {
|
||||
): Promise<SeedAgentTimelineOptions & { historyComplete?: boolean }> {
|
||||
if (!this.durableTimelineStore) {
|
||||
return { timestamp: now.toISOString() };
|
||||
}
|
||||
|
||||
const snapshot = await this.durableTimelineStore.getCommittedSnapshot(agentId);
|
||||
const rows = snapshot.rows;
|
||||
return {
|
||||
nextSeq: (await this.durableTimelineStore.getLatestCommittedSeq(agentId)) + 1,
|
||||
rows,
|
||||
nextSeq: rows.length > 0 ? rows[rows.length - 1]!.seq + 1 : 1,
|
||||
timestamp: now.toISOString(),
|
||||
historyComplete: snapshot.historyComplete,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3196,6 +3413,11 @@ export class AgentManager {
|
||||
},
|
||||
"agent.manager.enqueue",
|
||||
);
|
||||
const steerBarrier = this.steerEventBarriers.get(agentId);
|
||||
if (steerBarrier) {
|
||||
steerBarrier.events.push(event);
|
||||
return;
|
||||
}
|
||||
const pendingRun = this.runs.getPendingRun(agentId);
|
||||
if (pendingRun && !pendingRun.started) {
|
||||
pendingRun.stagedEvents.push(event);
|
||||
@@ -3424,7 +3646,8 @@ export class AgentManager {
|
||||
): Promise<void> {
|
||||
const historyEvents: Extract<AgentStreamEvent, { type: "timeline" }>[] = [];
|
||||
const providerSubagentEvents: Extract<AgentStreamEvent, { type: "provider_subagent" }>[] = [];
|
||||
for await (const event of agent.session.streamHistory()) {
|
||||
for await (const rawEvent of agent.session.streamHistory()) {
|
||||
const event = limitAgentStreamEventContent(rawEvent);
|
||||
if (event.type === "timeline") {
|
||||
if (event.item.type === "user_message" && isSystemInjectedEnvelope(event.item.text)) {
|
||||
continue;
|
||||
@@ -3435,11 +3658,17 @@ export class AgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
const reconciledRows = reconcileProviderHistory(
|
||||
this.timelineStore.getRows(agent.id),
|
||||
historyEvents.map((event) => ({ item: event.item, timestamp: event.timestamp })),
|
||||
{ mode: "force" },
|
||||
);
|
||||
this.agentStreamCoalescer.flushAndDiscard(agent.id);
|
||||
await this.durableTimelineBuffer?.flush(agent.id);
|
||||
await this.deleteCommittedTimeline(agent.id);
|
||||
this.timelineStore.delete(agent.id);
|
||||
this.timelineStore.initialize(agent.id, { timestamp: new Date().toISOString() });
|
||||
agent.historyPrimed = true;
|
||||
this.timelineStore.initialize(agent.id, { rows: reconciledRows });
|
||||
agent.historyPrimed = false;
|
||||
|
||||
for (const event of this.providerSubagents.deleteParent(agent.id)) {
|
||||
if (broadcast) {
|
||||
@@ -3452,20 +3681,11 @@ export class AgentManager {
|
||||
this.dispatch({ type: "provider_subagent", event: update });
|
||||
}
|
||||
}
|
||||
for (const event of historyEvents) {
|
||||
const row = this.recordTimeline(
|
||||
agent.id,
|
||||
event.item,
|
||||
event.timestamp ? { timestamp: event.timestamp } : undefined,
|
||||
);
|
||||
if (broadcast) {
|
||||
this.dispatchStream(agent.id, event, {
|
||||
seq: row.seq,
|
||||
epoch: this.timelineStore.getEpoch(agent.id),
|
||||
timestamp: row.timestamp,
|
||||
});
|
||||
}
|
||||
if (broadcast) {
|
||||
this.dispatchReconciledTimelineRows(agent, reconciledRows);
|
||||
}
|
||||
await this.commitCompleteHistorySnapshot(agent.id);
|
||||
agent.historyPrimed = true;
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
}
|
||||
@@ -3475,14 +3695,13 @@ export class AgentManager {
|
||||
broadcast: boolean | (() => boolean),
|
||||
): Promise<void> {
|
||||
const deferredBroadcast = typeof broadcast === "function";
|
||||
const timelineEvents: Array<{
|
||||
event: Extract<AgentStreamEvent, { type: "timeline" }>;
|
||||
row: AgentTimelineRow;
|
||||
}> = [];
|
||||
let timelineRows: AgentTimelineRow[] = [];
|
||||
const providerSubagentEvents: AgentManagerEvent[] = [];
|
||||
agent.historyPrimed = true;
|
||||
const historyEvents: Extract<AgentStreamEvent, { type: "timeline" }>[] = [];
|
||||
agent.historyPrimed = false;
|
||||
try {
|
||||
for await (const event of agent.session.streamHistory()) {
|
||||
for await (const rawEvent of agent.session.streamHistory()) {
|
||||
const event = limitAgentStreamEventContent(rawEvent);
|
||||
if (event.type === "provider_subagent") {
|
||||
const update = this.providerSubagents.apply(agent.id, event.provider, event.event);
|
||||
const managerEvent: AgentManagerEvent = { type: "provider_subagent", event: update };
|
||||
@@ -3499,23 +3718,27 @@ export class AgentManager {
|
||||
if (event.item.type === "user_message" && isSystemInjectedEnvelope(event.item.text)) {
|
||||
continue;
|
||||
}
|
||||
const row = this.recordTimeline(
|
||||
agent.id,
|
||||
event.item,
|
||||
event.timestamp ? { timestamp: event.timestamp } : undefined,
|
||||
);
|
||||
if (deferredBroadcast) {
|
||||
timelineEvents.push({ event, row });
|
||||
} else if (broadcast) {
|
||||
this.dispatchStream(agent.id, event, {
|
||||
seq: row.seq,
|
||||
epoch: this.timelineStore.getEpoch(agent.id),
|
||||
timestamp: row.timestamp,
|
||||
});
|
||||
}
|
||||
historyEvents.push(event);
|
||||
}
|
||||
} catch {
|
||||
// ignore history failures
|
||||
|
||||
const reconciledRows = reconcileProviderHistory(
|
||||
this.timelineStore.getRows(agent.id),
|
||||
historyEvents.map((event) => ({ item: event.item, timestamp: event.timestamp })),
|
||||
);
|
||||
this.timelineStore.initialize(agent.id, { rows: reconciledRows });
|
||||
if (deferredBroadcast) {
|
||||
timelineRows = reconciledRows;
|
||||
} else if (broadcast) {
|
||||
this.dispatchReconciledTimelineRows(agent, reconciledRows);
|
||||
}
|
||||
await this.commitCompleteHistorySnapshot(agent.id);
|
||||
agent.historyPrimed = true;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{ err: error, agentId: agent.id },
|
||||
"Failed to commit provider history snapshot",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (typeof broadcast !== "function" || !broadcast()) {
|
||||
@@ -3524,15 +3747,42 @@ export class AgentManager {
|
||||
for (const event of providerSubagentEvents) {
|
||||
this.dispatch(event);
|
||||
}
|
||||
for (const { event, row } of timelineEvents) {
|
||||
this.dispatchStream(agent.id, event, {
|
||||
seq: row.seq,
|
||||
epoch: this.timelineStore.getEpoch(agent.id),
|
||||
timestamp: row.timestamp,
|
||||
});
|
||||
this.dispatchReconciledTimelineRows(agent, timelineRows);
|
||||
}
|
||||
|
||||
private dispatchReconciledTimelineRows(
|
||||
agent: ActiveManagedAgent,
|
||||
rows: readonly AgentTimelineRow[],
|
||||
): void {
|
||||
const epoch = this.timelineStore.getEpoch(agent.id);
|
||||
for (const row of rows) {
|
||||
this.dispatchStream(
|
||||
agent.id,
|
||||
{
|
||||
type: "timeline",
|
||||
provider: agent.provider,
|
||||
item: row.item,
|
||||
...(row.turnId ? { turnId: row.turnId } : {}),
|
||||
timestamp: row.timestamp,
|
||||
},
|
||||
{
|
||||
seq: row.seq,
|
||||
epoch,
|
||||
timestamp: row.timestamp,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async commitCompleteHistorySnapshot(agentId: string): Promise<void> {
|
||||
if (!this.durableTimelineStore) return;
|
||||
await this.durableTimelineBuffer?.flush(agentId);
|
||||
await this.durableTimelineStore.replaceCommittedSnapshot(agentId, {
|
||||
rows: this.timelineStore.getRows(agentId),
|
||||
historyComplete: true,
|
||||
});
|
||||
}
|
||||
|
||||
private notifyForegroundTurnWaiters(agentId: string, event: AgentStreamEvent): void {
|
||||
const turnId = getAgentStreamEventTurnId(event);
|
||||
if (turnId == null) {
|
||||
@@ -3812,7 +4062,7 @@ export class AgentManager {
|
||||
if (
|
||||
event.item.type === "user_message" &&
|
||||
event.item.clientMessageId &&
|
||||
this.reconcileSubmittedPromptEcho(agent, event.item)
|
||||
this.reconcileSubmittedPromptEcho(agent, event.item, event.turnId)
|
||||
) {
|
||||
flags.shouldDispatchEvent = false;
|
||||
flags.shouldNotifyWaiters = false;
|
||||
@@ -4045,7 +4295,7 @@ export class AgentManager {
|
||||
turnId?: string,
|
||||
options?: { providerMessageId?: string },
|
||||
): AgentStreamEvent {
|
||||
const row = this.recordTimeline(agentId, item, options);
|
||||
const row = this.recordTimeline(agentId, item, { ...options, turnId });
|
||||
const event: AgentStreamEvent = {
|
||||
type: "timeline",
|
||||
item,
|
||||
@@ -4077,7 +4327,7 @@ export class AgentManager {
|
||||
agent: ActiveManagedAgent,
|
||||
prompt: AgentPromptInput,
|
||||
clientMessageId: string,
|
||||
options?: { messageId?: string; providerMessageId?: string },
|
||||
options?: { messageId?: string; providerMessageId?: string; turnId?: string },
|
||||
): void {
|
||||
if (this.timelineStore.getSubmittedUserMessage(agent.id, clientMessageId)) {
|
||||
return;
|
||||
@@ -4090,16 +4340,25 @@ export class AgentManager {
|
||||
clientMessageId,
|
||||
...(options?.messageId ? { messageId: options.messageId } : {}),
|
||||
};
|
||||
this.recordAndDispatchTimelineItem(agent.id, item, agent.provider, undefined, options);
|
||||
this.recordAndDispatchTimelineItem(agent.id, item, agent.provider, options?.turnId, options);
|
||||
}
|
||||
|
||||
private reconcileSubmittedPromptEcho(
|
||||
agent: ActiveManagedAgent,
|
||||
item: Extract<AgentTimelineItem, { type: "user_message" }>,
|
||||
turnId?: string,
|
||||
): AgentTimelineRow | null {
|
||||
const { clientMessageId, messageId } = item;
|
||||
if (!clientMessageId) return null;
|
||||
const existing = this.timelineStore.getSubmittedUserMessage(agent.id, clientMessageId);
|
||||
let existing = this.timelineStore.getSubmittedUserMessage(agent.id, clientMessageId);
|
||||
if (!existing) {
|
||||
this.recordSubmittedPrompt(agent, item.text, clientMessageId, {
|
||||
messageId: clientMessageId,
|
||||
...(messageId ? { providerMessageId: messageId } : {}),
|
||||
...(turnId ? { turnId } : {}),
|
||||
});
|
||||
existing = this.timelineStore.getSubmittedUserMessage(agent.id, clientMessageId);
|
||||
}
|
||||
if (!existing || existing.item.type !== "user_message") return null;
|
||||
if (messageId) {
|
||||
const enriched = this.timelineStore.enrichSubmittedUserMessage(
|
||||
@@ -4169,11 +4428,18 @@ export class AgentManager {
|
||||
private recordTimeline(
|
||||
agentId: string,
|
||||
item: AgentTimelineItem,
|
||||
options?: { timestamp?: string; providerMessageId?: string },
|
||||
options?: {
|
||||
timestamp?: string;
|
||||
providerMessageId?: string;
|
||||
turnId?: string;
|
||||
persist?: boolean;
|
||||
},
|
||||
): AgentTimelineRow {
|
||||
item = limitAgentTimelineItemContent(item);
|
||||
const row = this.timelineStore.append(agentId, item, options);
|
||||
this.enqueueDurableTimelineAppend(agentId, row);
|
||||
if (options?.persist !== false) {
|
||||
this.enqueueDurableTimelineAppend(agentId, row);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -4260,19 +4526,10 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
private enqueueDurableTimelineAppend(agentId: string, row: AgentTimelineRow): void {
|
||||
if (!this.durableTimelineStore) {
|
||||
if (!this.durableTimelineBuffer) {
|
||||
return;
|
||||
}
|
||||
const task = this.durableTimelineStore
|
||||
.bulkInsert(agentId, [row])
|
||||
.then(() => undefined)
|
||||
.catch((err) => {
|
||||
this.logger.error(
|
||||
{ err, agentId, seq: row.seq, itemType: row.item.type },
|
||||
"Failed to append timeline row to durable store",
|
||||
);
|
||||
});
|
||||
this.trackBackgroundTask(task);
|
||||
this.durableTimelineBuffer.enqueue(agentId, row);
|
||||
}
|
||||
|
||||
private enqueueDurableTimelineBulkInsert(
|
||||
@@ -4282,18 +4539,15 @@ export class AgentManager {
|
||||
if (!this.durableTimelineStore || rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
const task = this.durableTimelineStore.bulkInsert(agentId, rows).catch((err) => {
|
||||
this.logger.error(
|
||||
{ err, agentId, rowCount: rows.length },
|
||||
"Failed to seed durable timeline store",
|
||||
);
|
||||
});
|
||||
this.trackBackgroundTask(task);
|
||||
for (const row of rows) this.durableTimelineBuffer?.enqueue(agentId, row);
|
||||
}
|
||||
|
||||
private enqueueDurableTimelineUpdate(agentId: string, row: AgentTimelineRow): void {
|
||||
if (!this.durableTimelineStore) return;
|
||||
const task = this.durableTimelineStore.updateCommittedRow(agentId, row).catch((err) => {
|
||||
const task = (async () => {
|
||||
await this.durableTimelineBuffer?.flush(agentId);
|
||||
await this.durableTimelineStore?.updateCommittedRow(agentId, row);
|
||||
})().catch((err) => {
|
||||
this.logger.error(
|
||||
{ err, agentId, seq: row.seq, itemType: row.item.type },
|
||||
"Failed to enrich durable timeline row",
|
||||
@@ -4340,6 +4594,7 @@ export class AgentManager {
|
||||
|
||||
private async flushTasks(options: { includeAgentRegistrations: boolean }): Promise<void> {
|
||||
this.agentStreamCoalescer.flushAll();
|
||||
await this.durableTimelineBuffer?.flushAll();
|
||||
// Drain tasks, including tasks spawned while awaiting.
|
||||
while (
|
||||
this.backgroundTasks.size > 0 ||
|
||||
|
||||
@@ -7,7 +7,6 @@ import { AgentStorage } from "./agent-storage.js";
|
||||
import {
|
||||
formatSystemNotificationPrompt,
|
||||
isSystemInjectedEnvelope,
|
||||
sendPromptToAgent,
|
||||
setupFinishNotification,
|
||||
} from "./agent-prompt.js";
|
||||
import type { AgentManagerEvent, ManagedAgent } from "./agent-manager.js";
|
||||
@@ -245,45 +244,6 @@ test("isSystemInjectedEnvelope matches the envelope formatSystemNotificationProm
|
||||
expect(isSystemInjectedEnvelope("hello world")).toBe(false);
|
||||
});
|
||||
|
||||
test("sendPromptToAgent forwards the client message id as run options", async () => {
|
||||
const agent: ManagedAgent = Object.create(null);
|
||||
Reflect.set(agent, "id", "agent-1");
|
||||
Reflect.set(agent, "provider", "codex");
|
||||
|
||||
const streamAgentSpy = vi.fn(() => (async function* noop() {})());
|
||||
const agentManager: AgentManager = Object.create(AgentManager.prototype);
|
||||
Reflect.set(
|
||||
agentManager,
|
||||
"getAgent",
|
||||
vi.fn(() => agent),
|
||||
);
|
||||
Reflect.set(agentManager, "tryRunOutOfBand", vi.fn().mockReturnValue(false));
|
||||
Reflect.set(agentManager, "hasInFlightRun", vi.fn().mockReturnValue(false));
|
||||
Reflect.set(agentManager, "streamAgent", streamAgentSpy);
|
||||
|
||||
const agentStorage: AgentStorage = Object.create(AgentStorage.prototype);
|
||||
Reflect.set(
|
||||
agentStorage,
|
||||
"get",
|
||||
vi.fn(async () => null),
|
||||
);
|
||||
|
||||
await sendPromptToAgent({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
agentId: "agent-1",
|
||||
prompt: "hello",
|
||||
messageId: "msg-client-1",
|
||||
runOptions: { outputSchema: { type: "object" } },
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
expect(streamAgentSpy).toHaveBeenCalledWith("agent-1", "hello", {
|
||||
outputSchema: { type: "object" },
|
||||
clientMessageId: "msg-client-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("finish notifications tell the parent the child's last assistant message", async () => {
|
||||
const scenario = createFinishNotificationScenario({
|
||||
childLastAssistantMessage: "Implemented the cleanup and all checks pass.",
|
||||
|
||||
@@ -9,26 +9,77 @@ import type { AgentManager, ManagedAgent } from "./agent-manager.js";
|
||||
import type { AgentStorage } from "./agent-storage.js";
|
||||
import { ensureAgentLoaded } from "./agent-loading.js";
|
||||
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
|
||||
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
|
||||
|
||||
export type AgentUnarchiveController = Pick<AgentManager, "notifyAgentState" | "unarchiveSnapshot">;
|
||||
|
||||
export type AgentRunController = Pick<
|
||||
AgentManager,
|
||||
"getAgent" | "tryRunOutOfBand" | "hasInFlightRun" | "replaceAgentRun" | "streamAgent"
|
||||
| "getAgent"
|
||||
| "tryRunOutOfBand"
|
||||
| "hasInFlightRun"
|
||||
| "replaceAgentRun"
|
||||
| "steerOrReplaceActiveTurn"
|
||||
| "streamAgent"
|
||||
>;
|
||||
|
||||
export interface StartAgentRunOptions {
|
||||
replaceRunning?: boolean;
|
||||
activeTurnBehavior?: ActiveTurnBehavior;
|
||||
runOptions?: AgentRunOptions;
|
||||
}
|
||||
|
||||
export type PromptDispatchDisposition = "out_of_band" | "steered" | "turn_started";
|
||||
|
||||
async function steerOrReplaceActiveRun(
|
||||
agentManager: AgentRunController,
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
options: StartAgentRunOptions | undefined,
|
||||
): Promise<
|
||||
| { disposition: "steered" }
|
||||
| {
|
||||
disposition: "turn_started";
|
||||
iterator: AsyncGenerator<import("./agent-sdk-types.js").AgentStreamEvent>;
|
||||
}
|
||||
| null
|
||||
> {
|
||||
if (options?.activeTurnBehavior !== "steer") {
|
||||
return null;
|
||||
}
|
||||
const result = await agentManager.steerOrReplaceActiveTurn(agentId, prompt, options.runOptions);
|
||||
if (result.status === "steered") {
|
||||
return { disposition: "steered" };
|
||||
}
|
||||
if (result.status === "replaced") {
|
||||
return { disposition: "turn_started", iterator: result.iterator };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function startOrReplaceRun(
|
||||
agentManager: AgentRunController,
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
options: StartAgentRunOptions | undefined,
|
||||
): Promise<{
|
||||
iterator: AsyncGenerator<import("./agent-sdk-types.js").AgentStreamEvent>;
|
||||
replaced: boolean;
|
||||
}> {
|
||||
const replaced = Boolean(options?.replaceRunning && agentManager.hasInFlightRun(agentId));
|
||||
const iterator = replaced
|
||||
? await agentManager.replaceAgentRun(agentId, prompt, options?.runOptions)
|
||||
: agentManager.streamAgent(agentId, prompt, options?.runOptions);
|
||||
return { iterator, replaced };
|
||||
}
|
||||
|
||||
export async function startAgentRun(
|
||||
agentManager: AgentRunController,
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
logger: Logger,
|
||||
options?: StartAgentRunOptions,
|
||||
): Promise<{ outOfBand: boolean }> {
|
||||
): Promise<{ disposition: PromptDispatchDisposition }> {
|
||||
const snapshot = agentManager.getAgent(agentId);
|
||||
logger.trace(
|
||||
{
|
||||
@@ -46,19 +97,21 @@ export async function startAgentRun(
|
||||
// in-flight turn — replaceAgentRun would interrupt the running turn. The
|
||||
// intercept lives at this layer so it covers every prompt entrypoint.
|
||||
if (agentManager.tryRunOutOfBand(agentId, prompt, options?.runOptions)) {
|
||||
return { outOfBand: true };
|
||||
return { disposition: "out_of_band" };
|
||||
}
|
||||
const shouldReplace = Boolean(options?.replaceRunning && agentManager.hasInFlightRun(agentId));
|
||||
const runOptions = options?.runOptions;
|
||||
const iterator = shouldReplace
|
||||
? await agentManager.replaceAgentRun(agentId, prompt, runOptions)
|
||||
: agentManager.streamAgent(agentId, prompt, runOptions);
|
||||
const steered = await steerOrReplaceActiveRun(agentManager, agentId, prompt, options);
|
||||
if (steered?.disposition === "steered") {
|
||||
return steered;
|
||||
}
|
||||
const { iterator, replaced } = steered
|
||||
? { iterator: steered.iterator, replaced: true }
|
||||
: await startOrReplaceRun(agentManager, agentId, prompt, options);
|
||||
logger.trace(
|
||||
{
|
||||
agentId,
|
||||
provider: snapshot?.provider,
|
||||
providerSessionId: snapshot?.persistence?.sessionId ?? undefined,
|
||||
shouldReplace,
|
||||
shouldReplace: replaced,
|
||||
},
|
||||
"agent.session.start_stream.iterator_returned",
|
||||
);
|
||||
@@ -88,7 +141,7 @@ export async function startAgentRun(
|
||||
logger.error({ err: error, agentId }, "Agent stream failed");
|
||||
}
|
||||
})();
|
||||
return { outOfBand: false };
|
||||
return { disposition: "turn_started" };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,6 +183,7 @@ export interface SendPromptToAgentParams {
|
||||
/** Prompt to dispatch to the provider (may include image blocks or wrapped text). */
|
||||
prompt: AgentPromptInput;
|
||||
messageId?: string;
|
||||
activeTurnBehavior?: ActiveTurnBehavior;
|
||||
runOptions?: AgentRunOptions;
|
||||
/** Optional mode to set on the agent before the run starts. */
|
||||
sessionMode?: string;
|
||||
@@ -176,17 +230,17 @@ export async function waitForAgentRunStartWithTimeout(
|
||||
* drift between them.
|
||||
*
|
||||
* When `unarchive` is false and the agent is archived, the call is a silent
|
||||
* no-op (returns `{ outOfBand: false }`) — the agent is not run.
|
||||
* no-op (returns the normal turn-start disposition) — the agent is not run.
|
||||
*/
|
||||
export async function sendPromptToAgent(
|
||||
params: SendPromptToAgentParams,
|
||||
): Promise<{ outOfBand: boolean }> {
|
||||
): Promise<{ disposition: PromptDispatchDisposition }> {
|
||||
const unarchive = params.unarchive ?? true;
|
||||
|
||||
const record = await params.agentStorage.get(params.agentId);
|
||||
if (record?.archivedAt) {
|
||||
if (!unarchive) {
|
||||
return { outOfBand: false };
|
||||
return { disposition: "turn_started" };
|
||||
}
|
||||
await unarchiveAgentState(params.agentStorage, params.agentManager, params.agentId);
|
||||
}
|
||||
@@ -207,6 +261,7 @@ export async function sendPromptToAgent(
|
||||
|
||||
return await startAgentRun(params.agentManager, params.agentId, params.prompt, params.logger, {
|
||||
replaceRunning: true,
|
||||
activeTurnBehavior: params.activeTurnBehavior,
|
||||
runOptions,
|
||||
});
|
||||
}
|
||||
@@ -233,7 +288,7 @@ export async function startCreatedAgentInitialPrompt(
|
||||
},
|
||||
);
|
||||
|
||||
if (!dispatchResult.outOfBand) {
|
||||
if (dispatchResult.disposition === "turn_started") {
|
||||
await waitForAgentRunStartWithTimeout(params.agentManager, params.agentId);
|
||||
}
|
||||
|
||||
|
||||
@@ -214,6 +214,12 @@ export interface AgentRunOptions {
|
||||
clientMessageId?: string;
|
||||
}
|
||||
|
||||
export type SteerResult = { status: "accepted" } | { status: "unavailable" };
|
||||
|
||||
export interface SteerActiveTurnOptions extends AgentRunOptions {
|
||||
expectedTurnId: string;
|
||||
}
|
||||
|
||||
export interface AgentUsage {
|
||||
inputTokens?: number;
|
||||
cachedInputTokens?: number;
|
||||
@@ -629,6 +635,7 @@ export interface AgentSession {
|
||||
readonly features?: AgentFeature[];
|
||||
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
|
||||
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
|
||||
steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>;
|
||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
|
||||
streamHistory(): AsyncGenerator<AgentStreamEvent>;
|
||||
getRuntimeInfo(): Promise<AgentRuntimeInfo>;
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface AgentTimelineRow {
|
||||
seq: number;
|
||||
timestamp: string;
|
||||
item: AgentTimelineItem;
|
||||
readonly turnId?: string;
|
||||
readonly providerMessageId?: string;
|
||||
}
|
||||
|
||||
@@ -31,6 +32,12 @@ export interface AgentTimelineWindow {
|
||||
nextSeq: number;
|
||||
}
|
||||
|
||||
/** A committed canonical transcript and whether provider history is fully represented. */
|
||||
export interface AgentTimelineSnapshot {
|
||||
rows: AgentTimelineRow[];
|
||||
historyComplete: boolean;
|
||||
}
|
||||
|
||||
export interface AgentTimelineFetchResult {
|
||||
epoch: string;
|
||||
direction: AgentTimelineFetchDirection;
|
||||
@@ -47,7 +54,7 @@ export interface AgentTimelineStore {
|
||||
appendCommitted(
|
||||
agentId: string,
|
||||
item: AgentTimelineItem,
|
||||
options?: { timestamp?: string },
|
||||
options?: { timestamp?: string; turnId?: string },
|
||||
): Promise<AgentTimelineRow>;
|
||||
fetchCommitted(
|
||||
agentId: string,
|
||||
@@ -55,9 +62,11 @@ export interface AgentTimelineStore {
|
||||
): Promise<AgentTimelineFetchResult>;
|
||||
getLatestCommittedSeq(agentId: string): Promise<number>;
|
||||
getCommittedRows(agentId: string): Promise<AgentTimelineRow[]>;
|
||||
getCommittedSnapshot(agentId: string): Promise<AgentTimelineSnapshot>;
|
||||
getLastItem(agentId: string): Promise<AgentTimelineItem | null>;
|
||||
getLastAssistantMessage(agentId: string): Promise<string | null>;
|
||||
deleteAgent(agentId: string): Promise<void>;
|
||||
bulkInsert(agentId: string, rows: readonly AgentTimelineRow[]): Promise<void>;
|
||||
replaceCommittedSnapshot(agentId: string, snapshot: AgentTimelineSnapshot): Promise<void>;
|
||||
updateCommittedRow(agentId: string, row: AgentTimelineRow): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -264,13 +264,14 @@ export class InMemoryAgentTimelineStore {
|
||||
append(
|
||||
agentId: string,
|
||||
item: AgentTimelineItem,
|
||||
options?: { timestamp?: string; providerMessageId?: string },
|
||||
options?: { timestamp?: string; providerMessageId?: string; turnId?: string },
|
||||
): AgentTimelineRow {
|
||||
const state = this.requireState(agentId);
|
||||
const row: AgentTimelineRow = {
|
||||
seq: state.nextSeq,
|
||||
timestamp: options?.timestamp ?? new Date().toISOString(),
|
||||
item,
|
||||
...(options?.turnId ? { turnId: options.turnId } : {}),
|
||||
...(options?.providerMessageId ? { providerMessageId: options.providerMessageId } : {}),
|
||||
};
|
||||
state.nextSeq += 1;
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { DurableTimelineBuffer } from "./durable-timeline-buffer.js";
|
||||
import type {
|
||||
AgentTimelineFetchResult,
|
||||
AgentTimelineRow,
|
||||
AgentTimelineSnapshot,
|
||||
AgentTimelineStore,
|
||||
} from "./agent-timeline-store-types.js";
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
|
||||
const row = (seq: number): AgentTimelineRow => ({
|
||||
seq,
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
item: { type: "assistant_message", text: String(seq) },
|
||||
});
|
||||
|
||||
class RecordingStore implements AgentTimelineStore {
|
||||
readonly batches: AgentTimelineRow[][] = [];
|
||||
failuresRemaining = 0;
|
||||
|
||||
async appendCommitted(): Promise<AgentTimelineRow> {
|
||||
throw new Error("not used");
|
||||
}
|
||||
async fetchCommitted(): Promise<AgentTimelineFetchResult> {
|
||||
throw new Error("not used");
|
||||
}
|
||||
async getLatestCommittedSeq(): Promise<number> {
|
||||
return 0;
|
||||
}
|
||||
async getCommittedRows(): Promise<AgentTimelineRow[]> {
|
||||
return [];
|
||||
}
|
||||
async getCommittedSnapshot(): Promise<AgentTimelineSnapshot> {
|
||||
return { rows: [], historyComplete: false };
|
||||
}
|
||||
async getLastItem(): Promise<AgentTimelineItem | null> {
|
||||
return null;
|
||||
}
|
||||
async getLastAssistantMessage(): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
async deleteAgent(): Promise<void> {}
|
||||
async bulkInsert(_agentId: string, rows: readonly AgentTimelineRow[]): Promise<void> {
|
||||
this.batches.push([...rows]);
|
||||
if (this.failuresRemaining > 0) {
|
||||
this.failuresRemaining -= 1;
|
||||
throw new Error("disk full");
|
||||
}
|
||||
}
|
||||
async replaceCommittedSnapshot(): Promise<void> {}
|
||||
async updateCommittedRow(): Promise<void> {}
|
||||
}
|
||||
|
||||
class ControlledTimers {
|
||||
readonly delays: number[] = [];
|
||||
private readonly callbacks = new Map<number, () => void>();
|
||||
private nextId = 1;
|
||||
|
||||
setTimeout = (callback: () => void, delayMs: number): ReturnType<typeof setTimeout> => {
|
||||
const id = this.nextId++;
|
||||
this.delays.push(delayMs);
|
||||
this.callbacks.set(id, callback);
|
||||
return id as unknown as ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
clearTimeout = (timer: ReturnType<typeof setTimeout>): void => {
|
||||
this.callbacks.delete(timer as unknown as number);
|
||||
};
|
||||
|
||||
async runNext(): Promise<void> {
|
||||
const next = this.callbacks.entries().next().value as [number, () => void] | undefined;
|
||||
if (!next) throw new Error("Expected a scheduled timer");
|
||||
this.callbacks.delete(next[0]);
|
||||
next[1]();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.callbacks.size;
|
||||
}
|
||||
}
|
||||
|
||||
const timers = {
|
||||
setTimeout: () => 0 as unknown as ReturnType<typeof setTimeout>,
|
||||
clearTimeout: () => undefined,
|
||||
};
|
||||
|
||||
describe("DurableTimelineBuffer", () => {
|
||||
test("retries a failed prefix with later rows without a sequence hole", async () => {
|
||||
const store = new RecordingStore();
|
||||
store.failuresRemaining = 1;
|
||||
const buffer = new DurableTimelineBuffer(store, 25, timers, () => undefined);
|
||||
|
||||
buffer.enqueue("agent", row(1));
|
||||
await expect(buffer.flush("agent")).rejects.toThrow("disk full");
|
||||
buffer.enqueue("agent", row(2));
|
||||
await buffer.flush("agent");
|
||||
|
||||
const batchSequences = store.batches.map(batchSequencesFor);
|
||||
expect(batchSequences).toEqual([[1], [1, 2]]);
|
||||
});
|
||||
|
||||
test("coalesces a burst into one bounded atomic write and flush drains it", async () => {
|
||||
const store = new RecordingStore();
|
||||
const buffer = new DurableTimelineBuffer(store, 25, timers, () => undefined);
|
||||
|
||||
for (let seq = 1; seq <= 50; seq += 1) buffer.enqueue("agent", row(seq));
|
||||
await buffer.flushAll();
|
||||
|
||||
expect(store.batches).toHaveLength(1);
|
||||
expect(store.batches[0]?.map((entry) => entry.seq)).toEqual(
|
||||
Array.from({ length: 50 }, (_, index) => index + 1),
|
||||
);
|
||||
});
|
||||
|
||||
test("discard drops a pending batch so a later global flush cannot resurrect it", async () => {
|
||||
const store = new RecordingStore();
|
||||
const buffer = new DurableTimelineBuffer(store, 25, timers, () => undefined);
|
||||
|
||||
buffer.enqueue("agent", row(1));
|
||||
await buffer.discard("agent");
|
||||
await buffer.flushAll();
|
||||
buffer.enqueue("agent", row(1));
|
||||
await buffer.flushAll();
|
||||
|
||||
expect(store.batches).toEqual([[row(1)]]);
|
||||
});
|
||||
|
||||
test("retries a final failed batch without another enqueue", async () => {
|
||||
const store = new RecordingStore();
|
||||
store.failuresRemaining = 1;
|
||||
const controlledTimers = new ControlledTimers();
|
||||
const errors: unknown[] = [];
|
||||
const buffer = new DurableTimelineBuffer(
|
||||
store,
|
||||
25,
|
||||
controlledTimers,
|
||||
(_agentId, error) => errors.push(error),
|
||||
{ baseDelayMs: 10, maxDelayMs: 40 },
|
||||
);
|
||||
|
||||
buffer.enqueue("agent", row(1));
|
||||
await controlledTimers.runNext();
|
||||
expect(store.batches.map(batchSequencesFor)).toEqual([[1]]);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(controlledTimers.delays).toEqual([25, 10]);
|
||||
|
||||
await controlledTimers.runNext();
|
||||
expect(store.batches.map(batchSequencesFor)).toEqual([[1], [1]]);
|
||||
expect(controlledTimers.size).toBe(0);
|
||||
});
|
||||
|
||||
test("grows retry delays to a deterministic cap", async () => {
|
||||
const store = new RecordingStore();
|
||||
store.failuresRemaining = 4;
|
||||
const controlledTimers = new ControlledTimers();
|
||||
const buffer = new DurableTimelineBuffer(store, 25, controlledTimers, () => undefined, {
|
||||
baseDelayMs: 10,
|
||||
maxDelayMs: 40,
|
||||
});
|
||||
|
||||
buffer.enqueue("agent", row(1));
|
||||
await controlledTimers.runNext();
|
||||
await controlledTimers.runNext();
|
||||
await controlledTimers.runNext();
|
||||
await controlledTimers.runNext();
|
||||
await controlledTimers.runNext();
|
||||
|
||||
expect(controlledTimers.delays).toEqual([25, 10, 20, 40, 40]);
|
||||
expect(store.batches).toHaveLength(5);
|
||||
});
|
||||
|
||||
test("joins a later enqueue behind the retained failed prefix", async () => {
|
||||
const store = new RecordingStore();
|
||||
store.failuresRemaining = 1;
|
||||
const controlledTimers = new ControlledTimers();
|
||||
const buffer = new DurableTimelineBuffer(store, 25, controlledTimers, () => undefined, {
|
||||
baseDelayMs: 10,
|
||||
});
|
||||
|
||||
buffer.enqueue("agent", row(1));
|
||||
await controlledTimers.runNext();
|
||||
buffer.enqueue("agent", row(2));
|
||||
await controlledTimers.runNext();
|
||||
|
||||
expect(store.batches.map(batchSequencesFor)).toEqual([[1], [1, 2]]);
|
||||
});
|
||||
|
||||
test("discard cancels a retry after failure", async () => {
|
||||
const store = new RecordingStore();
|
||||
store.failuresRemaining = 1;
|
||||
const controlledTimers = new ControlledTimers();
|
||||
const buffer = new DurableTimelineBuffer(store, 25, controlledTimers, () => undefined, {
|
||||
baseDelayMs: 10,
|
||||
});
|
||||
|
||||
buffer.enqueue("agent", row(1));
|
||||
await controlledTimers.runNext();
|
||||
await buffer.discard("agent");
|
||||
|
||||
expect(controlledTimers.size).toBe(0);
|
||||
expect(store.batches.map(batchSequencesFor)).toEqual([[1]]);
|
||||
});
|
||||
|
||||
test("manual flush rejects while retaining an autonomous retry", async () => {
|
||||
const store = new RecordingStore();
|
||||
store.failuresRemaining = 1;
|
||||
const controlledTimers = new ControlledTimers();
|
||||
const buffer = new DurableTimelineBuffer(store, 25, controlledTimers, () => undefined, {
|
||||
baseDelayMs: 10,
|
||||
});
|
||||
|
||||
buffer.enqueue("agent", row(1));
|
||||
await expect(buffer.flush("agent")).rejects.toThrow("disk full");
|
||||
expect(controlledTimers.delays).toEqual([25, 10]);
|
||||
|
||||
await controlledTimers.runNext();
|
||||
expect(store.batches.map(batchSequencesFor)).toEqual([[1], [1]]);
|
||||
});
|
||||
});
|
||||
|
||||
function batchSequencesFor(batch: AgentTimelineRow[]): number[] {
|
||||
return batch.map((entry) => entry.seq);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { AgentTimelineRow, AgentTimelineStore } from "./agent-timeline-store-types.js";
|
||||
|
||||
export interface DurableTimelineTimers {
|
||||
setTimeout(callback: () => void, delayMs: number): ReturnType<typeof setTimeout>;
|
||||
clearTimeout(timer: ReturnType<typeof setTimeout>): void;
|
||||
}
|
||||
|
||||
export interface DurableTimelineRetryOptions {
|
||||
baseDelayMs?: number;
|
||||
maxDelayMs?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_RETRY_MAX_DELAY_MS = 5_000;
|
||||
|
||||
interface PendingAgentRows {
|
||||
rows: AgentTimelineRow[];
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
flushing: Promise<void> | null;
|
||||
retryAttempt: number;
|
||||
}
|
||||
|
||||
/** Keeps each agent's durable transcript contiguous while coalescing live writes. */
|
||||
export class DurableTimelineBuffer {
|
||||
private readonly pending = new Map<string, PendingAgentRows>();
|
||||
|
||||
constructor(
|
||||
private readonly store: AgentTimelineStore,
|
||||
private readonly delayMs: number,
|
||||
private readonly timers: DurableTimelineTimers,
|
||||
private readonly onError: (agentId: string, error: unknown) => void,
|
||||
private readonly retryOptions: DurableTimelineRetryOptions = {},
|
||||
) {}
|
||||
|
||||
enqueue(agentId: string, row: AgentTimelineRow): void {
|
||||
const state = this.stateFor(agentId);
|
||||
state.rows.push(row);
|
||||
this.schedule(agentId, state, this.delayMs);
|
||||
}
|
||||
|
||||
async flush(agentId: string): Promise<void> {
|
||||
const state = this.pending.get(agentId);
|
||||
if (!state) return;
|
||||
if (state.timer !== null) {
|
||||
this.timers.clearTimeout(state.timer);
|
||||
state.timer = null;
|
||||
}
|
||||
if (state.flushing) {
|
||||
await state.flushing;
|
||||
return this.flush(agentId);
|
||||
}
|
||||
if (state.rows.length === 0) {
|
||||
this.pending.delete(agentId);
|
||||
return;
|
||||
}
|
||||
const batch = [...state.rows];
|
||||
state.flushing = this.store.bulkInsert(agentId, batch);
|
||||
try {
|
||||
await state.flushing;
|
||||
state.rows.splice(0, batch.length);
|
||||
state.retryAttempt = 0;
|
||||
} catch (error) {
|
||||
this.scheduleRetry(agentId, state);
|
||||
throw error;
|
||||
} finally {
|
||||
state.flushing = null;
|
||||
if (state.rows.length === 0 && state.timer === null) {
|
||||
this.pending.delete(agentId);
|
||||
} else if (state.rows.length > 0 && state.timer === null) {
|
||||
this.schedule(agentId, state, this.delayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async flushAll(): Promise<void> {
|
||||
await Promise.all([...this.pending.keys()].map((agentId) => this.flush(agentId)));
|
||||
}
|
||||
|
||||
async discard(agentId: string): Promise<void> {
|
||||
const state = this.pending.get(agentId);
|
||||
if (!state) return;
|
||||
if (state.timer !== null) this.timers.clearTimeout(state.timer);
|
||||
state.timer = null;
|
||||
state.rows.length = 0;
|
||||
await state.flushing?.catch(() => undefined);
|
||||
this.pending.delete(agentId);
|
||||
}
|
||||
|
||||
private stateFor(agentId: string): PendingAgentRows {
|
||||
let state = this.pending.get(agentId);
|
||||
if (!state) {
|
||||
state = { rows: [], timer: null, flushing: null, retryAttempt: 0 };
|
||||
this.pending.set(agentId, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
private schedule(agentId: string, state: PendingAgentRows, delayMs: number): void {
|
||||
if (state.timer !== null || state.rows.length === 0) return;
|
||||
state.timer = this.timers.setTimeout(() => {
|
||||
state.timer = null;
|
||||
void this.flush(agentId).catch((error) => this.onError(agentId, error));
|
||||
}, delayMs);
|
||||
this.unref(state.timer);
|
||||
}
|
||||
|
||||
private scheduleRetry(agentId: string, state: PendingAgentRows): void {
|
||||
const baseDelayMs = this.retryOptions.baseDelayMs ?? this.delayMs;
|
||||
const maxDelayMs = this.retryOptions.maxDelayMs ?? DEFAULT_RETRY_MAX_DELAY_MS;
|
||||
const delayMs = Math.min(baseDelayMs * 2 ** state.retryAttempt, maxDelayMs);
|
||||
state.retryAttempt += 1;
|
||||
this.schedule(agentId, state, delayMs);
|
||||
}
|
||||
|
||||
private unref(timer: ReturnType<typeof setTimeout>): void {
|
||||
const candidate = timer as unknown as { unref?: () => void };
|
||||
candidate.unref?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { mkdtemp, readdir, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { writeJsonFileAtomic } from "../atomic-file.js";
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { FileAgentTimelineStore } from "./file-agent-timeline-store.js";
|
||||
import type { AgentTimelineRow } from "./agent-timeline-store-types.js";
|
||||
|
||||
const user = (text: string) => ({ type: "user_message" as const, text });
|
||||
|
||||
async function storeDirectory(): Promise<string> {
|
||||
return await mkdtemp(path.join(tmpdir(), "paseo-timeline-"));
|
||||
}
|
||||
|
||||
function filePath(directory: string, agentId: string): string {
|
||||
return path.join(directory, `agent-${Buffer.from(agentId, "utf8").toString("base64url")}.json`);
|
||||
}
|
||||
|
||||
function document(rows: AgentTimelineRow[], nextSeq: number) {
|
||||
return { version: 1, epoch: "epoch-1", nextSeq, rows };
|
||||
}
|
||||
|
||||
describe("FileAgentTimelineStore", () => {
|
||||
it("loads a missing agent file as an empty timeline", async () => {
|
||||
const store = new FileAgentTimelineStore(await storeDirectory());
|
||||
expect(await store.getCommittedRows("agent")).toEqual([]);
|
||||
});
|
||||
|
||||
it("loads legacy rows without turnId", async () => {
|
||||
const directory = await storeDirectory();
|
||||
await writeJsonFileAtomic(
|
||||
filePath(directory, "agent"),
|
||||
document([{ seq: 1, timestamp: "2026-01-01T00:00:00.000Z", item: user("old") }], 2),
|
||||
);
|
||||
|
||||
expect(
|
||||
(await new FileAgentTimelineStore(directory).getCommittedRows("agent"))[0]?.turnId,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats existing rows as incomplete until an atomic history snapshot commits", async () => {
|
||||
const directory = await storeDirectory();
|
||||
const store = new FileAgentTimelineStore(directory);
|
||||
const row = { seq: 1, timestamp: "2026-01-01T00:00:00.000Z", item: user("old") };
|
||||
|
||||
await store.bulkInsert("agent", [row]);
|
||||
expect((await store.getCommittedSnapshot("agent")).historyComplete).toBe(false);
|
||||
|
||||
await store.replaceCommittedSnapshot("agent", { rows: [row], historyComplete: true });
|
||||
expect(await new FileAgentTimelineStore(directory).getCommittedSnapshot("agent")).toEqual({
|
||||
rows: [row],
|
||||
historyComplete: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips supplied canonical rows without changing sequence or identity", async () => {
|
||||
const directory = await storeDirectory();
|
||||
const row: AgentTimelineRow = {
|
||||
seq: 7,
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
item: { type: "user_message", text: "hello", clientMessageId: "hello-client" },
|
||||
turnId: "turn-1",
|
||||
providerMessageId: "provider-hello",
|
||||
};
|
||||
|
||||
await new FileAgentTimelineStore(directory).bulkInsert("agent", [row]);
|
||||
const reopened = new FileAgentTimelineStore(directory);
|
||||
expect(await reopened.getCommittedRows("agent")).toEqual([row]);
|
||||
expect((await reopened.fetchCommitted("agent", { limit: 0 })).window.nextSeq).toBe(8);
|
||||
});
|
||||
|
||||
it("treats duplicate supplied rows as idempotent and rejects conflicting sequence reuse", async () => {
|
||||
const directory = await storeDirectory();
|
||||
let writeCount = 0;
|
||||
const store = new FileAgentTimelineStore(directory, {
|
||||
writeJson: async (file, value) => {
|
||||
writeCount += 1;
|
||||
await writeJsonFileAtomic(file, value);
|
||||
},
|
||||
});
|
||||
const row: AgentTimelineRow = {
|
||||
seq: 3,
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
item: user("hello"),
|
||||
providerMessageId: "provider-hello",
|
||||
turnId: "turn-1",
|
||||
};
|
||||
|
||||
await store.bulkInsert("agent", [row]);
|
||||
await store.bulkInsert("agent", [row]);
|
||||
expect(writeCount).toBe(1);
|
||||
await expect(store.bulkInsert("agent", [{ ...row, item: user("different") }])).rejects.toThrow(
|
||||
"Conflicting timeline row sequence 3",
|
||||
);
|
||||
expect(await store.getCommittedRows("agent")).toEqual([row]);
|
||||
});
|
||||
|
||||
it("serializes concurrent mutations for one agent without losing rows", async () => {
|
||||
const store = new FileAgentTimelineStore(await storeDirectory());
|
||||
await Promise.all([
|
||||
store.appendCommitted("agent", user("one")),
|
||||
store.appendCommitted("agent", user("two")),
|
||||
]);
|
||||
expect((await store.getCommittedRows("agent")).map((row) => row.seq)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("updates an existing row exactly and rejects a missing row sequence", async () => {
|
||||
const store = new FileAgentTimelineStore(await storeDirectory());
|
||||
await store.bulkInsert("agent", [
|
||||
{ seq: 1, timestamp: "2026-01-01T00:00:00.000Z", item: user("before") },
|
||||
]);
|
||||
const replacement: AgentTimelineRow = {
|
||||
seq: 1,
|
||||
timestamp: "2026-01-01T00:00:01.000Z",
|
||||
item: user("after"),
|
||||
turnId: "turn-1",
|
||||
providerMessageId: "provider-1",
|
||||
};
|
||||
|
||||
await store.updateCommittedRow("agent", replacement);
|
||||
expect(await store.getCommittedRows("agent")).toEqual([replacement]);
|
||||
await expect(store.updateCommittedRow("agent", { ...replacement, seq: 2 })).rejects.toThrow(
|
||||
"Cannot update missing timeline row sequence 2",
|
||||
);
|
||||
});
|
||||
|
||||
it("recovers an agent mutation lane after a rejected write without committing the failed row", async () => {
|
||||
const directory = await storeDirectory();
|
||||
let writeCount = 0;
|
||||
const store = new FileAgentTimelineStore(directory, {
|
||||
writeJson: async (file, value) => {
|
||||
writeCount += 1;
|
||||
if (writeCount === 1) throw new Error("disk full");
|
||||
await writeJsonFileAtomic(file, value);
|
||||
},
|
||||
});
|
||||
|
||||
await expect(store.appendCommitted("agent", user("lost"))).rejects.toThrow("disk full");
|
||||
expect(await store.getCommittedRows("agent")).toEqual([]);
|
||||
await expect(store.appendCommitted("agent", user("saved"))).resolves.toMatchObject({ seq: 1 });
|
||||
expect(await store.getCommittedRows("agent")).toEqual([
|
||||
expect.objectContaining({ seq: 1, item: user("saved") }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("isolates corrupt files and failed mutation lanes to their own agent", async () => {
|
||||
const directory = await storeDirectory();
|
||||
await writeFile(filePath(directory, "broken"), "not json");
|
||||
const store = new FileAgentTimelineStore(directory, {
|
||||
writeJson: async (file, value) => {
|
||||
if (file === filePath(directory, "failing")) throw new Error("failing agent write");
|
||||
await writeJsonFileAtomic(file, value);
|
||||
},
|
||||
});
|
||||
|
||||
await expect(store.getCommittedRows("broken")).rejects.toThrow();
|
||||
await expect(store.appendCommitted("valid", user("works"))).resolves.toMatchObject({ seq: 1 });
|
||||
await expect(store.appendCommitted("failing", user("fails"))).rejects.toThrow(
|
||||
"failing agent write",
|
||||
);
|
||||
await expect(store.appendCommitted("valid", user("still works"))).resolves.toMatchObject({
|
||||
seq: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not serialize agent B behind a held agent A write", async () => {
|
||||
const directory = await storeDirectory();
|
||||
let releaseA!: () => void;
|
||||
const aWrite = new Promise<void>((resolve) => {
|
||||
releaseA = resolve;
|
||||
});
|
||||
const store = new FileAgentTimelineStore(directory, {
|
||||
writeJson: async (file, value) => {
|
||||
if (file === filePath(directory, "agent-a")) await aWrite;
|
||||
await writeJsonFileAtomic(file, value);
|
||||
},
|
||||
});
|
||||
|
||||
const appendA = store.appendCommitted("agent-a", user("held"));
|
||||
await expect(store.appendCommitted("agent-b", user("independent"))).resolves.toMatchObject({
|
||||
seq: 1,
|
||||
});
|
||||
releaseA();
|
||||
await expect(appendA).resolves.toMatchObject({ seq: 1 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "invalid timeline item",
|
||||
value: document([{ seq: 1, timestamp: "now", item: { type: "not-a-timeline-item" } }], 2),
|
||||
},
|
||||
{
|
||||
name: "out-of-order rows",
|
||||
value: document(
|
||||
[
|
||||
{ seq: 2, timestamp: "now", item: user("two") },
|
||||
{ seq: 1, timestamp: "now", item: user("one") },
|
||||
],
|
||||
3,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "next sequence at the committed maximum",
|
||||
value: document([{ seq: 1, timestamp: "now", item: user("one") }], 1),
|
||||
},
|
||||
])("rejects persisted $name", async ({ value }) => {
|
||||
const directory = await storeDirectory();
|
||||
await writeJsonFileAtomic(filePath(directory, "agent"), value);
|
||||
await expect(new FileAgentTimelineStore(directory).getCommittedRows("agent")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("writes outside AgentStorage discovery", async () => {
|
||||
const root = await storeDirectory();
|
||||
const agentStorageDirectory = path.join(root, "agents");
|
||||
const timelineDirectory = path.join(root, "timelines");
|
||||
const store = new FileAgentTimelineStore(timelineDirectory);
|
||||
|
||||
await store.appendCommitted("agent", user("hello"));
|
||||
|
||||
expect(await readdir(timelineDirectory)).toEqual(["agent-YWdlbnQ.json"]);
|
||||
expect(await new AgentStorage(agentStorageDirectory, createTestLogger()).list()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { z } from "zod";
|
||||
|
||||
import { writeJsonFileAtomic } from "../atomic-file.js";
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
|
||||
import { AgentTimelineItemPayloadSchema } from "@getpaseo/protocol/messages";
|
||||
import type {
|
||||
AgentTimelineFetchOptions,
|
||||
AgentTimelineFetchResult,
|
||||
AgentTimelineRow,
|
||||
AgentTimelineSnapshot,
|
||||
AgentTimelineStore,
|
||||
} from "./agent-timeline-store-types.js";
|
||||
|
||||
const TimelineRowSchema: z.ZodType<AgentTimelineRow, unknown> = z.object({
|
||||
seq: z.number().int().positive(),
|
||||
timestamp: z.string(),
|
||||
item: AgentTimelineItemPayloadSchema,
|
||||
turnId: z.string().optional(),
|
||||
providerMessageId: z.string().optional(),
|
||||
});
|
||||
|
||||
const TimelineDocumentSchema = z.object({
|
||||
version: z.literal(1),
|
||||
epoch: z.string(),
|
||||
nextSeq: z.number().int().positive(),
|
||||
rows: z.array(TimelineRowSchema),
|
||||
// Documents written before history completeness existed are intentionally incomplete.
|
||||
historyComplete: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
type TimelineDocument = z.infer<typeof TimelineDocumentSchema>;
|
||||
|
||||
export interface FileAgentTimelineStoreOptions {
|
||||
writeJson?: (filePath: string, value: unknown) => Promise<void>;
|
||||
}
|
||||
|
||||
function cloneRow(row: AgentTimelineRow): AgentTimelineRow {
|
||||
return { ...row, item: structuredClone(row.item) };
|
||||
}
|
||||
|
||||
function cloneDocument(document: TimelineDocument): TimelineDocument {
|
||||
return { ...document, rows: document.rows.map(cloneRow) };
|
||||
}
|
||||
|
||||
function validateDocument(value: unknown): TimelineDocument {
|
||||
const document = TimelineDocumentSchema.parse(value);
|
||||
let previousSeq = 0;
|
||||
for (const row of document.rows) {
|
||||
if (row.seq <= previousSeq) {
|
||||
throw new Error("Timeline rows must have strictly increasing sequence numbers");
|
||||
}
|
||||
previousSeq = row.seq;
|
||||
}
|
||||
if (document.nextSeq <= previousSeq) {
|
||||
throw new Error("Timeline nextSeq must be greater than every row sequence number");
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
function emptyDocument(): TimelineDocument {
|
||||
return { version: 1, epoch: randomUUID(), nextSeq: 1, rows: [], historyComplete: false };
|
||||
}
|
||||
|
||||
function fileNameForAgent(agentId: string): string {
|
||||
return `agent-${Buffer.from(agentId, "utf8").toString("base64url")}.json`;
|
||||
}
|
||||
|
||||
function selectRows(
|
||||
document: TimelineDocument,
|
||||
options?: AgentTimelineFetchOptions,
|
||||
): AgentTimelineFetchResult {
|
||||
const memory = new InMemoryAgentTimelineStore();
|
||||
memory.initialize("timeline", document);
|
||||
return memory.fetch("timeline", options);
|
||||
}
|
||||
|
||||
/** Durable canonical timeline rows, isolated as one atomic document per agent. */
|
||||
export class FileAgentTimelineStore implements AgentTimelineStore {
|
||||
private readonly documents = new Map<string, TimelineDocument>();
|
||||
private readonly loading = new Map<string, Promise<TimelineDocument>>();
|
||||
private readonly mutationTails = new Map<string, Promise<void>>();
|
||||
private readonly writeJson: (filePath: string, value: unknown) => Promise<void>;
|
||||
|
||||
constructor(
|
||||
private readonly directory: string,
|
||||
options?: FileAgentTimelineStoreOptions,
|
||||
) {
|
||||
this.writeJson = options?.writeJson ?? writeJsonFileAtomic;
|
||||
}
|
||||
|
||||
async appendCommitted(
|
||||
agentId: string,
|
||||
item: AgentTimelineItem,
|
||||
options?: { timestamp?: string; turnId?: string },
|
||||
): Promise<AgentTimelineRow> {
|
||||
return this.mutate(agentId, (document) => {
|
||||
const row: AgentTimelineRow = {
|
||||
seq: document.nextSeq,
|
||||
timestamp: options?.timestamp ?? new Date().toISOString(),
|
||||
item: structuredClone(item),
|
||||
...(options?.turnId ? { turnId: options.turnId } : {}),
|
||||
};
|
||||
document.rows.push(row);
|
||||
document.nextSeq += 1;
|
||||
return cloneRow(row);
|
||||
});
|
||||
}
|
||||
|
||||
async fetchCommitted(
|
||||
agentId: string,
|
||||
options?: AgentTimelineFetchOptions,
|
||||
): Promise<AgentTimelineFetchResult> {
|
||||
return selectRows(await this.getDocument(agentId), options);
|
||||
}
|
||||
|
||||
async getLatestCommittedSeq(agentId: string): Promise<number> {
|
||||
return (await this.getDocument(agentId)).rows.at(-1)?.seq ?? 0;
|
||||
}
|
||||
|
||||
async getCommittedRows(agentId: string): Promise<AgentTimelineRow[]> {
|
||||
return (await this.getDocument(agentId)).rows.map(cloneRow);
|
||||
}
|
||||
|
||||
async getCommittedSnapshot(agentId: string): Promise<AgentTimelineSnapshot> {
|
||||
const document = await this.getDocument(agentId);
|
||||
return { rows: document.rows.map(cloneRow), historyComplete: document.historyComplete };
|
||||
}
|
||||
|
||||
async getLastItem(agentId: string): Promise<AgentTimelineItem | null> {
|
||||
const item = (await this.getDocument(agentId)).rows.at(-1)?.item;
|
||||
return item ? structuredClone(item) : null;
|
||||
}
|
||||
|
||||
async getLastAssistantMessage(agentId: string): Promise<string | null> {
|
||||
const rows = (await this.getDocument(agentId)).rows;
|
||||
const chunks: string[] = [];
|
||||
for (let index = rows.length - 1; index >= 0; index -= 1) {
|
||||
const item = rows[index]!.item;
|
||||
if (item.type !== "assistant_message") {
|
||||
if (chunks.length > 0) break;
|
||||
continue;
|
||||
}
|
||||
chunks.push(item.text);
|
||||
}
|
||||
return chunks.length > 0 ? chunks.toReversed().join("") : null;
|
||||
}
|
||||
|
||||
async deleteAgent(agentId: string): Promise<void> {
|
||||
await this.runMutation(agentId, async () => {
|
||||
await fs.rm(this.filePath(agentId), { force: true });
|
||||
this.documents.delete(agentId);
|
||||
});
|
||||
}
|
||||
|
||||
async bulkInsert(agentId: string, rows: readonly AgentTimelineRow[]): Promise<void> {
|
||||
await this.runMutation(agentId, async () => {
|
||||
const current = await this.getDocument(agentId);
|
||||
const proposed = cloneDocument(current);
|
||||
let changed = false;
|
||||
for (const row of rows) {
|
||||
const parsed = TimelineRowSchema.parse(row);
|
||||
const existing = proposed.rows.find((candidate) => candidate.seq === parsed.seq);
|
||||
if (existing) {
|
||||
if (!isDeepStrictEqual(existing, parsed)) {
|
||||
throw new Error(`Conflicting timeline row sequence ${parsed.seq}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
proposed.rows.push(cloneRow(parsed));
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) return;
|
||||
proposed.rows.sort((left, right) => left.seq - right.seq);
|
||||
const maxSeq = proposed.rows.at(-1)?.seq ?? 0;
|
||||
proposed.nextSeq = Math.max(proposed.nextSeq, maxSeq + 1);
|
||||
validateDocument(proposed);
|
||||
await this.writeJson(this.filePath(agentId), proposed);
|
||||
this.documents.set(agentId, proposed);
|
||||
});
|
||||
}
|
||||
|
||||
async replaceCommittedSnapshot(agentId: string, snapshot: AgentTimelineSnapshot): Promise<void> {
|
||||
await this.runMutation(agentId, async () => {
|
||||
const current = await this.getDocument(agentId);
|
||||
const rows = snapshot.rows.map((row) => cloneRow(TimelineRowSchema.parse(row)));
|
||||
const nextSeq = (rows.at(-1)?.seq ?? 0) + 1;
|
||||
const proposed: TimelineDocument = {
|
||||
version: 1,
|
||||
epoch: current.epoch,
|
||||
nextSeq,
|
||||
rows,
|
||||
historyComplete: snapshot.historyComplete,
|
||||
};
|
||||
validateDocument(proposed);
|
||||
await this.writeJson(this.filePath(agentId), proposed);
|
||||
this.documents.set(agentId, proposed);
|
||||
});
|
||||
}
|
||||
|
||||
async updateCommittedRow(agentId: string, row: AgentTimelineRow): Promise<void> {
|
||||
await this.mutate(agentId, (document) => {
|
||||
const parsed = TimelineRowSchema.parse(row);
|
||||
const index = document.rows.findIndex((candidate) => candidate.seq === parsed.seq);
|
||||
if (index < 0) {
|
||||
throw new Error(`Cannot update missing timeline row sequence ${parsed.seq}`);
|
||||
}
|
||||
document.rows[index] = cloneRow(parsed);
|
||||
});
|
||||
}
|
||||
|
||||
private async mutate<T>(
|
||||
agentId: string,
|
||||
mutation: (document: TimelineDocument) => T,
|
||||
): Promise<T> {
|
||||
return this.runMutation(agentId, async () => {
|
||||
const current = await this.getDocument(agentId);
|
||||
const proposed = cloneDocument(current);
|
||||
const result = mutation(proposed);
|
||||
validateDocument(proposed);
|
||||
await this.writeJson(this.filePath(agentId), proposed);
|
||||
this.documents.set(agentId, proposed);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
private async getDocument(agentId: string): Promise<TimelineDocument> {
|
||||
const cached = this.documents.get(agentId);
|
||||
if (cached) return cached;
|
||||
let load = this.loading.get(agentId);
|
||||
if (!load) {
|
||||
load = this.loadDocument(agentId);
|
||||
this.loading.set(agentId, load);
|
||||
}
|
||||
try {
|
||||
const document = await load;
|
||||
this.documents.set(agentId, document);
|
||||
return document;
|
||||
} finally {
|
||||
if (this.loading.get(agentId) === load) this.loading.delete(agentId);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadDocument(agentId: string): Promise<TimelineDocument> {
|
||||
try {
|
||||
return validateDocument(JSON.parse(await fs.readFile(this.filePath(agentId), "utf8")));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return emptyDocument();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async runMutation<T>(agentId: string, operation: () => Promise<T>): Promise<T> {
|
||||
const previous = this.mutationTails.get(agentId) ?? Promise.resolve();
|
||||
const result = previous.catch(() => undefined).then(operation);
|
||||
const tail = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
this.mutationTails.set(agentId, tail);
|
||||
void tail.finally(() => {
|
||||
if (this.mutationTails.get(agentId) === tail) this.mutationTails.delete(agentId);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private filePath(agentId: string): string {
|
||||
return path.join(this.directory, fileNameForAgent(agentId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { reconcileProviderHistory } from "./history-reconciliation.js";
|
||||
|
||||
const user = (text: string, id?: string) => ({
|
||||
type: "user_message" as const,
|
||||
text,
|
||||
...(id ? { clientMessageId: id } : {}),
|
||||
});
|
||||
|
||||
describe("reconcileProviderHistory", () => {
|
||||
test("puts missing provider prefix before a newer durable suffix while preserving suffix metadata", () => {
|
||||
const rows = reconcileProviderHistory(
|
||||
[
|
||||
{
|
||||
seq: 2,
|
||||
timestamp: "2026-01-02T00:00:00.000Z",
|
||||
item: user("suffix", "suffix"),
|
||||
turnId: "turn-1",
|
||||
providerMessageId: "p-suffix",
|
||||
},
|
||||
],
|
||||
[
|
||||
{ item: user("prefix"), timestamp: "2026-01-01T00:00:00.000Z" },
|
||||
{ item: user("suffix"), timestamp: "2026-01-02T00:00:00.000Z" },
|
||||
],
|
||||
);
|
||||
expect(rows).toMatchObject([
|
||||
{ seq: 1, item: { text: "prefix" } },
|
||||
{
|
||||
seq: 2,
|
||||
turnId: "turn-1",
|
||||
providerMessageId: "p-suffix",
|
||||
item: { text: "suffix", clientMessageId: "suffix" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("pairs repeated identical user text by ordered occurrence", () => {
|
||||
const rows = reconcileProviderHistory(
|
||||
[
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
item: user("same", "one"),
|
||||
turnId: "turn-1",
|
||||
},
|
||||
{
|
||||
seq: 2,
|
||||
timestamp: "2026-01-02T00:00:00.000Z",
|
||||
item: user("same", "two"),
|
||||
turnId: "turn-2",
|
||||
},
|
||||
],
|
||||
[{ item: user("same") }, { item: user("same") }],
|
||||
);
|
||||
expect(
|
||||
rows.map((row) => [
|
||||
row.item.type === "user_message" ? row.item.clientMessageId : null,
|
||||
row.turnId,
|
||||
]),
|
||||
).toEqual([
|
||||
["one", "turn-1"],
|
||||
["two", "turn-2"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("retains a canonical suffix when provider history is lagging", () => {
|
||||
const rows = reconcileProviderHistory(
|
||||
[
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
item: user("initial", "initial"),
|
||||
turnId: "turn-1",
|
||||
},
|
||||
{
|
||||
seq: 2,
|
||||
timestamp: "2026-01-02T00:00:00.000Z",
|
||||
item: user("hello", "hello"),
|
||||
turnId: "turn-1",
|
||||
},
|
||||
],
|
||||
[{ item: user("initial") }],
|
||||
);
|
||||
|
||||
expect(rows).toMatchObject([
|
||||
{ seq: 1, item: { text: "initial", clientMessageId: "initial" }, turnId: "turn-1" },
|
||||
{ seq: 2, item: { text: "hello", clientMessageId: "hello" }, turnId: "turn-1" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not transfer provider identity between ambiguous repeated prompts", () => {
|
||||
const rows = reconcileProviderHistory(
|
||||
[
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
item: user("same", "one"),
|
||||
turnId: "turn-1",
|
||||
},
|
||||
{
|
||||
seq: 2,
|
||||
timestamp: "2026-01-02T00:00:00.000Z",
|
||||
item: user("same", "two"),
|
||||
turnId: "turn-2",
|
||||
},
|
||||
],
|
||||
[{ item: { type: "user_message", text: "same", messageId: "provider-two" } }],
|
||||
);
|
||||
|
||||
expect(rows).toMatchObject([
|
||||
{ item: { clientMessageId: "one" }, turnId: "turn-1" },
|
||||
{ item: { clientMessageId: "two" }, turnId: "turn-2" },
|
||||
]);
|
||||
expect(rows.some((row) => row.providerMessageId === "provider-two")).toBe(false);
|
||||
});
|
||||
|
||||
test("does not invent turn membership for provider-only rows", () => {
|
||||
expect(
|
||||
reconcileProviderHistory([], [{ item: { type: "assistant_message", text: "provider" } }])[0]
|
||||
?.turnId,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("clears unmatched rows for an authoritative forced history", () => {
|
||||
expect(
|
||||
reconcileProviderHistory(
|
||||
[{ seq: 1, timestamp: "now", item: user("old", "old"), turnId: "turn-1" }],
|
||||
[],
|
||||
{ mode: "force" },
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
import type { AgentTimelineRow } from "./agent-timeline-store-types.js";
|
||||
|
||||
export interface ProviderHistoryTimelineEntry {
|
||||
item: AgentTimelineItem;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
/** Reconciles canonical metadata onto provider-ordered history without inventing membership. */
|
||||
export function reconcileProviderHistory(
|
||||
canonicalRows: readonly AgentTimelineRow[],
|
||||
providerEntries: readonly ProviderHistoryTimelineEntry[],
|
||||
options?: { mode?: "incomplete" | "force" },
|
||||
): AgentTimelineRow[] {
|
||||
if (providerEntries.length === 0) {
|
||||
return options?.mode === "force"
|
||||
? []
|
||||
: canonicalRows.map((row, index) => ({ ...row, seq: index + 1 }));
|
||||
}
|
||||
const remaining = canonicalRows.map((row, canonicalIndex) => ({
|
||||
row,
|
||||
canonicalIndex,
|
||||
used: false,
|
||||
}));
|
||||
const structuralCounts = countStructuralOccurrences(canonicalRows, providerEntries);
|
||||
const providerRows = providerEntries.map((entry) => {
|
||||
const match = takeMatch(remaining, entry.item, structuralCounts);
|
||||
return { entry, match };
|
||||
});
|
||||
const rows: AgentTimelineRow[] = [];
|
||||
const emittedCanonicalIndexes = new Set<number>();
|
||||
|
||||
for (const { entry, match } of providerRows) {
|
||||
if (match) {
|
||||
for (const candidate of remaining) {
|
||||
if (candidate.canonicalIndex >= match.canonicalIndex) break;
|
||||
if (!emittedCanonicalIndexes.has(candidate.canonicalIndex)) {
|
||||
rows.push({ ...candidate.row });
|
||||
emittedCanonicalIndexes.add(candidate.canonicalIndex);
|
||||
}
|
||||
}
|
||||
rows.push(
|
||||
match.transferProviderIdentity ? mergeMatchedRow(match.row, entry) : { ...match.row },
|
||||
);
|
||||
emittedCanonicalIndexes.add(match.canonicalIndex);
|
||||
continue;
|
||||
}
|
||||
rows.push({
|
||||
seq: 0,
|
||||
timestamp: entry.timestamp ?? new Date(0).toISOString(),
|
||||
item: entry.item,
|
||||
});
|
||||
}
|
||||
|
||||
if (options?.mode !== "force") {
|
||||
for (const candidate of remaining) {
|
||||
if (!emittedCanonicalIndexes.has(candidate.canonicalIndex)) {
|
||||
rows.push({ ...candidate.row });
|
||||
}
|
||||
}
|
||||
}
|
||||
rows.forEach((row, index) => {
|
||||
row.seq = index + 1;
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
function takeMatch(
|
||||
remaining: Array<{ row: AgentTimelineRow; canonicalIndex: number; used: boolean }>,
|
||||
provider: AgentTimelineItem,
|
||||
structuralCounts: Map<string, { canonical: number; provider: number }>,
|
||||
): { row: AgentTimelineRow; canonicalIndex: number; transferProviderIdentity: boolean } | null {
|
||||
const strong = remaining.find(
|
||||
(candidate) => !candidate.used && hasSharedIdentity(candidate.row, provider),
|
||||
);
|
||||
const structural =
|
||||
strong ??
|
||||
remaining.find(
|
||||
(candidate) => !candidate.used && structurallyMatches(candidate.row.item, provider),
|
||||
);
|
||||
if (!structural) return null;
|
||||
structural.used = true;
|
||||
const key = structuralKey(provider);
|
||||
const counts = structuralCounts.get(key);
|
||||
return {
|
||||
row: structural.row,
|
||||
canonicalIndex: structural.canonicalIndex,
|
||||
transferProviderIdentity:
|
||||
strong !== undefined || (counts?.canonical === 1 && counts.provider === 1),
|
||||
};
|
||||
}
|
||||
|
||||
function mergeMatchedRow(
|
||||
canonical: AgentTimelineRow,
|
||||
provider: ProviderHistoryTimelineEntry,
|
||||
): AgentTimelineRow {
|
||||
return {
|
||||
...canonical,
|
||||
item: mergeCanonicalIdentity(canonical.item, provider.item),
|
||||
};
|
||||
}
|
||||
|
||||
function countStructuralOccurrences(
|
||||
canonicalRows: readonly AgentTimelineRow[],
|
||||
providerEntries: readonly ProviderHistoryTimelineEntry[],
|
||||
): Map<string, { canonical: number; provider: number }> {
|
||||
const counts = new Map<string, { canonical: number; provider: number }>();
|
||||
for (const row of canonicalRows) {
|
||||
const key = structuralKey(row.item);
|
||||
const count = counts.get(key) ?? { canonical: 0, provider: 0 };
|
||||
count.canonical += 1;
|
||||
counts.set(key, count);
|
||||
}
|
||||
for (const entry of providerEntries) {
|
||||
const key = structuralKey(entry.item);
|
||||
const count = counts.get(key) ?? { canonical: 0, provider: 0 };
|
||||
count.provider += 1;
|
||||
counts.set(key, count);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function structuralKey(item: AgentTimelineItem): string {
|
||||
return item.type === "user_message"
|
||||
? `user:${item.text}`
|
||||
: `${item.type}:${JSON.stringify(item)}`;
|
||||
}
|
||||
|
||||
function hasSharedIdentity(row: AgentTimelineRow, provider: AgentTimelineItem): boolean {
|
||||
if (row.item.type !== "user_message" || provider.type !== "user_message") return false;
|
||||
const identities = [row.item.clientMessageId, row.item.messageId, row.providerMessageId].filter(
|
||||
Boolean,
|
||||
);
|
||||
return identities.some(
|
||||
(identity) => identity === provider.clientMessageId || identity === provider.messageId,
|
||||
);
|
||||
}
|
||||
|
||||
function structurallyMatches(left: AgentTimelineItem, right: AgentTimelineItem): boolean {
|
||||
if (left.type === "user_message" && right.type === "user_message")
|
||||
return left.text === right.text;
|
||||
return isDeepStrictEqual(left, right);
|
||||
}
|
||||
|
||||
function mergeCanonicalIdentity(
|
||||
canonical: AgentTimelineItem,
|
||||
provider: AgentTimelineItem,
|
||||
): AgentTimelineItem {
|
||||
if (canonical.type !== "user_message" || provider.type !== "user_message") return provider;
|
||||
return {
|
||||
...provider,
|
||||
...(canonical.clientMessageId ? { clientMessageId: canonical.clientMessageId } : {}),
|
||||
...(canonical.messageId ? { messageId: canonical.messageId } : {}),
|
||||
};
|
||||
}
|
||||
@@ -829,6 +829,54 @@ describe("ClaudeAgentSession features", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("pushes a steer into the exact active Claude query without starting another turn", async () => {
|
||||
const { queryFactory } = createQueryMock();
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const session = await client.createSession({ provider: "claude", cwd: process.cwd() });
|
||||
const events: AgentStreamEvent[] = [];
|
||||
const unsubscribe = session.subscribe((event) => events.push(event));
|
||||
try {
|
||||
const { turnId } = await session.startTurn("first turn");
|
||||
const input = queryFactory.mock.calls[0]?.[0].prompt as AsyncIterable<SDKUserMessage>;
|
||||
const iterator = input[Symbol.asyncIterator]();
|
||||
await expect(iterator.next()).resolves.toMatchObject({ value: { type: "user" } });
|
||||
|
||||
await expect(
|
||||
session.steerActiveTurn?.("same turn follow-up", {
|
||||
expectedTurnId: turnId,
|
||||
clientMessageId: "steer-client-id",
|
||||
}),
|
||||
).resolves.toEqual({ status: "accepted" });
|
||||
await expect(iterator.next()).resolves.toMatchObject({
|
||||
value: {
|
||||
type: "user",
|
||||
priority: "next",
|
||||
message: { content: [{ type: "text", text: "same turn follow-up" }] },
|
||||
},
|
||||
});
|
||||
expect(events.filter((event) => event.type === "turn_started")).toHaveLength(1);
|
||||
|
||||
await expect(
|
||||
session.steerActiveTurn?.("/rewind submitted-message-id", { expectedTurnId: turnId }),
|
||||
).resolves.toEqual({ status: "unavailable" });
|
||||
let rewindReachedLiveInput = false;
|
||||
void iterator.next().then(() => {
|
||||
rewindReachedLiveInput = true;
|
||||
return undefined;
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(rewindReachedLiveInput).toBe(false);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
["supported model", "claude-opus-4-8", { type: "disabled" }, undefined],
|
||||
["unsupported model", "claude-fable-5", { type: "adaptive" }, "high"],
|
||||
|
||||
@@ -110,6 +110,8 @@ import {
|
||||
type AgentSession,
|
||||
type AgentSessionConfig,
|
||||
type AgentSlashCommand,
|
||||
type SteerActiveTurnOptions,
|
||||
type SteerResult,
|
||||
type AgentStreamEvent,
|
||||
type AgentTimelineItem,
|
||||
type AgentUsage,
|
||||
@@ -2021,6 +2023,9 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private query: Query | null = null;
|
||||
private childProcess: ChildProcess | null = null;
|
||||
private input: AsyncMessageInput<SDKUserMessage> | null = null;
|
||||
/** The exact SDK query/input pair that owns the current foreground turn. */
|
||||
private activeForegroundQuery: Query | null = null;
|
||||
private activeForegroundInput: AsyncMessageInput<SDKUserMessage> | null = null;
|
||||
private claudeSessionId: string | null;
|
||||
private persistence: AgentPersistenceHandle | null;
|
||||
private currentMode: PermissionMode;
|
||||
@@ -2236,6 +2241,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
if (!this.input) {
|
||||
throw new Error("Claude session input stream not initialized");
|
||||
}
|
||||
this.activeForegroundQuery = this.query;
|
||||
this.activeForegroundInput = this.input;
|
||||
this.startQueryPump();
|
||||
this.input.push(sdkMessage);
|
||||
setTimeout(() => {
|
||||
@@ -2252,6 +2259,39 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return { turnId };
|
||||
}
|
||||
|
||||
async steerActiveTurn(
|
||||
prompt: AgentPromptInput,
|
||||
options: SteerActiveTurnOptions,
|
||||
): Promise<SteerResult> {
|
||||
if (this.resolveSlashCommandInvocation(prompt)) {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
if (this.compacting || this.activeForegroundTurnId !== options.expectedTurnId) {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
|
||||
// Capture both ends of the live SDK stream before creating or delivering the message. There
|
||||
// is deliberately no await below: a finished A cannot make this input point at a later B.
|
||||
const query = this.activeForegroundQuery;
|
||||
const input = this.activeForegroundInput;
|
||||
if (!query || !input || this.query !== query || this.input !== input) {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
const message = this.toSdkUserMessage(prompt);
|
||||
message.priority = "next";
|
||||
if (
|
||||
this.activeForegroundTurnId !== options.expectedTurnId ||
|
||||
this.activeForegroundQuery !== query ||
|
||||
this.activeForegroundInput !== input ||
|
||||
this.query !== query ||
|
||||
this.input !== input
|
||||
) {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
input.push(message);
|
||||
return { status: "accepted" };
|
||||
}
|
||||
|
||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
|
||||
this.subscribers.add(callback);
|
||||
return () => {
|
||||
@@ -2527,6 +2567,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.cancelCurrentTurn?.();
|
||||
this.subscribers.clear();
|
||||
this.activeForegroundTurnId = null;
|
||||
this.activeForegroundQuery = null;
|
||||
this.activeForegroundInput = null;
|
||||
this.autonomousTurn = null;
|
||||
this.cancelCurrentTurn = null;
|
||||
this.turnState = "idle";
|
||||
@@ -2640,7 +2682,10 @@ class ClaudeAgentSession implements AgentSession {
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
return parsed.commandName === REWIND_COMMAND_NAME ? parsed : null;
|
||||
return parsed.commandName === REWIND_COMMAND_NAME ||
|
||||
CLAUDE_ROOT_ONLY_COMMANDS.has(parsed.commandName)
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
private parseSlashCommandInput(text: string): SlashCommandInvocation | null {
|
||||
@@ -3410,6 +3455,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
this.notifySubscribers(event);
|
||||
this.activeForegroundTurnId = null;
|
||||
this.activeForegroundQuery = null;
|
||||
this.activeForegroundInput = null;
|
||||
this.cancelCurrentTurn = null;
|
||||
this.activeTurnHasAssistantText = false;
|
||||
this.syncTurnState("foreground turn terminal");
|
||||
@@ -3425,6 +3472,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
if (terminalSeen) {
|
||||
if (this.activeForegroundTurnId) {
|
||||
this.activeForegroundTurnId = null;
|
||||
this.activeForegroundQuery = null;
|
||||
this.activeForegroundInput = null;
|
||||
this.cancelCurrentTurn = null;
|
||||
this.activeTurnHasAssistantText = false;
|
||||
this.syncTurnState("foreground turn terminal");
|
||||
@@ -3800,6 +3849,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.queryRestartNeeded = false;
|
||||
this.autonomousTurn = null;
|
||||
this.activeForegroundTurnId = null;
|
||||
this.activeForegroundQuery = null;
|
||||
this.activeForegroundInput = null;
|
||||
this.syncTurnState("missing resumed conversation");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ describe("mapCodexPlanUpdateToTodo", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
import { CodexAppServerClient } from "./codex/app-server-transport.js";
|
||||
import {
|
||||
createFakeCodexAppServer,
|
||||
@@ -144,6 +145,144 @@ function createProviderWithFakeAppServer(appServer: FakeCodexAppServer): CodexAp
|
||||
return provider;
|
||||
}
|
||||
|
||||
async function startPublicSteeringSession(
|
||||
appServer: FakeCodexAppServer,
|
||||
resolveSlashCommandInvocation?: (prompt: AgentPromptInput) => Promise<{
|
||||
commandName: string;
|
||||
args?: string;
|
||||
} | null>,
|
||||
): Promise<{ session: AgentSession; paseoTurnId: string }> {
|
||||
const session = new CodexAppServerAgentSession(
|
||||
createConfig({ cwd: "/workspace/project" }),
|
||||
null,
|
||||
createTestLogger(),
|
||||
async () => appServer.child,
|
||||
{ resolveSlashCommandInvocation },
|
||||
);
|
||||
const started = await session.startTurn("first");
|
||||
await appServer.waitForTurnStart();
|
||||
appServer.startsTurn({ threadId: "thread-1", turnId: "native-A" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
return { session, paseoTurnId: started.turnId };
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("Codex active-turn steering admission", () => {
|
||||
test("does not steer B when A completes while command resolution is pending", async () => {
|
||||
const commandResolution = deferred<{ commandName: string } | null>();
|
||||
const resolverEntered = deferred<void>();
|
||||
const appServer = createFakeCodexAppServer();
|
||||
const { session, paseoTurnId } = await startPublicSteeringSession(appServer, async (prompt) => {
|
||||
if (prompt !== "/held") return null;
|
||||
resolverEntered.resolve();
|
||||
return commandResolution.promise;
|
||||
});
|
||||
|
||||
const steer = session.steerActiveTurn!("/held", {
|
||||
expectedTurnId: paseoTurnId,
|
||||
clientMessageId: "steer-A",
|
||||
});
|
||||
await resolverEntered.promise;
|
||||
appServer.completeTurn({ threadId: "thread-1" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const startedB = await session.startTurn("second");
|
||||
appServer.startsTurn({ threadId: "thread-1", turnId: "native-B" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
commandResolution.resolve(null);
|
||||
|
||||
await expect(steer).resolves.toEqual({ status: "unavailable" });
|
||||
expect(startedB.turnId).not.toBe(paseoTurnId);
|
||||
expect(appServer.requests().filter((request) => request.method === "turn/steer")).toEqual([]);
|
||||
await session.close();
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test.each([
|
||||
["method unavailable", -32601, "method not found", undefined, "unavailable"],
|
||||
["no active turn", -32600, "no active turn to steer", undefined, "unavailable"],
|
||||
[
|
||||
"expected turn mismatch",
|
||||
-32600,
|
||||
"expected active turn id `native-A` but found `native-B`",
|
||||
undefined,
|
||||
"unavailable",
|
||||
],
|
||||
[
|
||||
"output schema mismatch",
|
||||
-32600,
|
||||
"active turn uses a different output schema",
|
||||
undefined,
|
||||
"unavailable",
|
||||
],
|
||||
[
|
||||
"review turn",
|
||||
-32600,
|
||||
"cannot steer a review turn",
|
||||
{ codexErrorInfo: { activeTurnNotSteerable: { turnKind: "review" } } },
|
||||
"unavailable",
|
||||
],
|
||||
["unknown invalid request", -32600, "input must not be empty", undefined, "throws"],
|
||||
] as const)("classifies JSON-RPC $0", async (_name, code, message, data, expected) => {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"turn/steer": () => ({ __jsonRpcError: { code, message, ...(data ? { data } : {}) } }),
|
||||
});
|
||||
const { session, paseoTurnId } = await startPublicSteeringSession(appServer);
|
||||
const steer = session.steerActiveTurn!("follow up", {
|
||||
expectedTurnId: paseoTurnId,
|
||||
clientMessageId: "steer-frame",
|
||||
});
|
||||
if (expected === "unavailable") {
|
||||
await expect(steer).resolves.toEqual({ status: "unavailable" });
|
||||
} else {
|
||||
await expect(steer).rejects.toThrow(message);
|
||||
}
|
||||
await session.close();
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("leaves a JSON-RPC server error ambiguous", async () => {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"turn/steer": () => ({
|
||||
__jsonRpcError: { code: -32000, message: "connection lost" },
|
||||
}),
|
||||
});
|
||||
const { session, paseoTurnId } = await startPublicSteeringSession(appServer);
|
||||
await expect(
|
||||
session.steerActiveTurn!("follow up", {
|
||||
expectedTurnId: paseoTurnId,
|
||||
clientMessageId: "steer-transport",
|
||||
}),
|
||||
).rejects.toThrow("connection lost");
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("rejects an in-flight steer when the app-server transport disconnects", async () => {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"turn/steer": () => new Promise<void>(() => undefined),
|
||||
});
|
||||
const { session, paseoTurnId } = await startPublicSteeringSession(appServer);
|
||||
const steer = session.steerActiveTurn!("follow up", {
|
||||
expectedTurnId: paseoTurnId,
|
||||
clientMessageId: "steer-disconnect",
|
||||
});
|
||||
await appServer.waitForRequest("turn/steer");
|
||||
appServer.disconnect();
|
||||
|
||||
await expect(steer).rejects.toThrow("Codex app-server exited");
|
||||
expect(appServer.requests().filter((request) => request.method === "turn/steer")).toHaveLength(
|
||||
1,
|
||||
);
|
||||
await session.close();
|
||||
});
|
||||
});
|
||||
|
||||
async function startCompactionTurnTest(): Promise<{
|
||||
appServer: FakeCodexAppServer;
|
||||
session: AgentSession;
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
type AgentRuntimeInfo,
|
||||
type AgentSession,
|
||||
type AgentSessionConfig,
|
||||
type SteerActiveTurnOptions,
|
||||
type SteerResult,
|
||||
type AgentSlashCommand,
|
||||
type AgentStreamEvent,
|
||||
type AgentTimelineItem,
|
||||
@@ -73,6 +75,7 @@ import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mappe
|
||||
import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js";
|
||||
import {
|
||||
CodexAppServerClient,
|
||||
CodexAppServerRpcError,
|
||||
parseCodexThreadForkResponse,
|
||||
parseCodexThreadRollbackResponse,
|
||||
type CodexThreadForkParams,
|
||||
@@ -261,6 +264,9 @@ interface CodexAppServerAgentDeps {
|
||||
logger: Logger,
|
||||
getTraceContext: () => CodexAppServerTraceContext,
|
||||
) => CodexAppServerClientLike;
|
||||
resolveSlashCommandInvocation?: (
|
||||
prompt: AgentPromptInput,
|
||||
) => Promise<{ commandName: string; args?: string } | null>;
|
||||
}
|
||||
|
||||
interface CodexModePreset {
|
||||
@@ -833,6 +839,24 @@ function toObjectRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return isRecord(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function isDefinitiveCodexSteerRejection(error: unknown): boolean {
|
||||
if (!(error instanceof CodexAppServerRpcError)) return false;
|
||||
if (error.code === -32601) return true;
|
||||
if (error.code !== -32600) return false;
|
||||
|
||||
const data = toObjectRecord(error.data);
|
||||
if (data && isRecord(toObjectRecord(data.codexErrorInfo)?.activeTurnNotSteerable)) return true;
|
||||
|
||||
// These app-server invalid-request messages describe requests that reached
|
||||
// Codex but could not have submitted input. Keep this exact: a generic
|
||||
// invalid-request, timeout, disconnect, or unknown error is ambiguous.
|
||||
return (
|
||||
error.message === "no active turn to steer" ||
|
||||
/^expected active turn id `[^`]+` but found `[^`]+`$/.test(error.message) ||
|
||||
error.message === "active turn uses a different output schema"
|
||||
);
|
||||
}
|
||||
|
||||
// Codex app-server API response types
|
||||
interface CodexReasoningEffortEntry {
|
||||
reasoningEffort?: string;
|
||||
@@ -1603,10 +1627,14 @@ function mapCodexThreadUserMessageItem(
|
||||
}
|
||||
const text = extractUserText(normalizedItem.content) ?? "";
|
||||
const messageId = nonEmptyString(normalizedItem.id);
|
||||
const clientMessageId = nonEmptyString(
|
||||
normalizedItem.clientId ?? normalizedItem.client_id ?? normalizedItem.clientUserMessageId,
|
||||
);
|
||||
return {
|
||||
type: "user_message",
|
||||
text,
|
||||
...(messageId ? { messageId } : {}),
|
||||
...(clientMessageId ? { clientMessageId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3808,6 +3836,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private async resolveSlashCommandInvocation(
|
||||
prompt: AgentPromptInput,
|
||||
): Promise<{ commandName: string; args?: string } | null> {
|
||||
if (this.deps.resolveSlashCommandInvocation) {
|
||||
return this.deps.resolveSlashCommandInvocation(prompt);
|
||||
}
|
||||
if (typeof prompt !== "string") {
|
||||
return null;
|
||||
}
|
||||
@@ -4096,6 +4127,63 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
async steerActiveTurn(
|
||||
prompt: AgentPromptInput,
|
||||
options: SteerActiveTurnOptions,
|
||||
): Promise<SteerResult> {
|
||||
const client = this.client;
|
||||
const threadId = this.currentThreadId;
|
||||
const nativeTurnId = this.currentTurnId;
|
||||
const foregroundTurnId = this.activeForegroundTurnId;
|
||||
if (!client || !threadId || !nativeTurnId || foregroundTurnId !== options.expectedTurnId) {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
if (await this.resolveSlashCommandInvocation(prompt)) return { status: "unavailable" };
|
||||
if (!this.matchesSteerAdmission({ client, threadId, nativeTurnId, foregroundTurnId })) {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
const input = await this.buildUserInput(prompt);
|
||||
if (!this.matchesSteerAdmission({ client, threadId, nativeTurnId, foregroundTurnId })) {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
try {
|
||||
const response = await client.request(
|
||||
"turn/steer",
|
||||
{
|
||||
threadId,
|
||||
expectedTurnId: nativeTurnId,
|
||||
input,
|
||||
...(options.clientMessageId ? { clientUserMessageId: options.clientMessageId } : {}),
|
||||
},
|
||||
TURN_START_TIMEOUT_MS,
|
||||
);
|
||||
const record = toObjectRecord(response);
|
||||
const turn = record ? toObjectRecord(record.turn) : null;
|
||||
const acknowledgedTurnId = nonEmptyString(record?.turnId) ?? nonEmptyString(turn?.id);
|
||||
if (acknowledgedTurnId !== nativeTurnId) {
|
||||
throw new Error("Codex returned an invalid steer acknowledgement");
|
||||
}
|
||||
return { status: "accepted" };
|
||||
} catch (error) {
|
||||
if (isDefinitiveCodexSteerRejection(error)) return { status: "unavailable" };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private matchesSteerAdmission(admission: {
|
||||
client: CodexAppServerClientLike;
|
||||
threadId: string;
|
||||
nativeTurnId: string;
|
||||
foregroundTurnId: string;
|
||||
}): boolean {
|
||||
return (
|
||||
this.client === admission.client &&
|
||||
this.currentThreadId === admission.threadId &&
|
||||
this.currentTurnId === admission.nativeTurnId &&
|
||||
this.activeForegroundTurnId === admission.foregroundTurnId
|
||||
);
|
||||
}
|
||||
|
||||
private rememberCodexUserMessageTurn(messageId: string | null | undefined): boolean {
|
||||
if (typeof messageId !== "string" || messageId.length === 0) {
|
||||
return false;
|
||||
@@ -6231,9 +6319,8 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
if (!this.rememberCodexUserMessageTurn(timelineItem.messageId)) {
|
||||
return;
|
||||
}
|
||||
const item = this.activeClientMessageId
|
||||
? { ...timelineItem, clientMessageId: this.activeClientMessageId }
|
||||
: timelineItem;
|
||||
const clientMessageId = timelineItem.clientMessageId ?? this.activeClientMessageId;
|
||||
const item = clientMessageId ? { ...timelineItem, clientMessageId } : timelineItem;
|
||||
this.activeClientMessageId = null;
|
||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item });
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ interface JsonRpcRequest {
|
||||
interface JsonRpcResponse {
|
||||
id: number;
|
||||
result?: unknown;
|
||||
error?: { message?: string };
|
||||
error?: { message?: string; code?: string | number; data?: unknown };
|
||||
}
|
||||
|
||||
interface JsonRpcNotification {
|
||||
@@ -33,6 +33,17 @@ interface PendingRequest {
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
export class CodexAppServerRpcError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly code: string | number | undefined,
|
||||
readonly data: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "CodexAppServerRpcError";
|
||||
}
|
||||
}
|
||||
|
||||
type RequestHandler = (params: unknown, requestId: number) => unknown;
|
||||
type NotificationHandler = (method: string, params: unknown) => void;
|
||||
type UnexpectedTerminationHandler = (error: Error) => void;
|
||||
@@ -329,7 +340,13 @@ export class CodexAppServerClient {
|
||||
clearTimeout(pending.timer);
|
||||
this.pending.delete(id);
|
||||
if (raw.error) {
|
||||
pending.reject(new Error(raw.error.message ?? "Unknown error"));
|
||||
pending.reject(
|
||||
new CodexAppServerRpcError(
|
||||
raw.error.message ?? "Unknown error",
|
||||
raw.error.code,
|
||||
raw.error.data,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
pending.resolve(raw.result);
|
||||
}
|
||||
|
||||
@@ -47,8 +47,11 @@ type CodexAppServerChildProcess = ChildProcessWithoutNullStreams & {
|
||||
export interface FakeCodexAppServer {
|
||||
readonly child: CodexAppServerChildProcess;
|
||||
readonly recordedRollbacks: JsonObject[];
|
||||
requests(): readonly JsonObject[];
|
||||
assertNoErrors(): void;
|
||||
waitForTurnStart(): Promise<JsonObject>;
|
||||
waitForRequest(method: string): Promise<JsonObject>;
|
||||
disconnect(): void;
|
||||
nextResponse(): Promise<string>;
|
||||
startsTurn(params: { threadId: string; turnId?: string }): void;
|
||||
startsCompaction(params: { threadId: string; itemId: string }): void;
|
||||
@@ -205,6 +208,11 @@ export function createFakeCodexAppServer(
|
||||
|
||||
Promise.resolve(handler(message.params))
|
||||
.then((result) => {
|
||||
const rpcError = toJsonObject(result).__jsonRpcError;
|
||||
if (rpcError) {
|
||||
child.stdout.write(`${JSON.stringify({ id: message.id, error: rpcError })}\n`);
|
||||
return undefined;
|
||||
}
|
||||
child.stdout.write(`${JSON.stringify({ id: message.id, result })}\n`);
|
||||
return undefined;
|
||||
})
|
||||
@@ -297,6 +305,9 @@ export function createFakeCodexAppServer(
|
||||
return {
|
||||
child,
|
||||
recordedRollbacks,
|
||||
requests() {
|
||||
return messages;
|
||||
},
|
||||
assertNoErrors() {
|
||||
if (errors.length > 0) {
|
||||
throw errors[0];
|
||||
@@ -309,6 +320,16 @@ export function createFakeCodexAppServer(
|
||||
);
|
||||
return toJsonObject(message.params);
|
||||
},
|
||||
async waitForRequest(method) {
|
||||
const message = await waitForMessage(
|
||||
(candidate) => candidate.method === method,
|
||||
`${method} request`,
|
||||
);
|
||||
return toJsonObject(message.params);
|
||||
},
|
||||
disconnect() {
|
||||
child.emit("exit", null, "SIGTERM");
|
||||
},
|
||||
nextResponse() {
|
||||
return new Promise<string>((resolve) => {
|
||||
child.stdin.once("data", (chunk) => resolve(chunk.toString()));
|
||||
|
||||
@@ -25,6 +25,8 @@ import type {
|
||||
ImportProviderSessionContext,
|
||||
ImportProviderSessionInput,
|
||||
ProviderCatalog,
|
||||
SteerActiveTurnOptions,
|
||||
SteerResult,
|
||||
ToolCallDetail,
|
||||
ToolCallTimelineItem,
|
||||
} from "../agent-sdk-types.js";
|
||||
@@ -37,6 +39,10 @@ export const MOCK_LOAD_TEST_HANDLED_COMMAND = "/mock handled-command";
|
||||
const MOCK_LOAD_TEST_MODE_ID = "load-test";
|
||||
const MOCK_LOAD_TEST_DURATION_MS = 5 * 60 * 1000;
|
||||
const MOCK_LOAD_TEST_INTERVAL_MS = 40;
|
||||
|
||||
function getPositiveFeatureInteger(value: unknown): number {
|
||||
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : 0;
|
||||
}
|
||||
const ONE_PIXEL_PNG_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl4Kj8AAAAASUVORK5CYII=";
|
||||
|
||||
@@ -160,6 +166,8 @@ interface AgentStreamStressRequest {
|
||||
coalesced: boolean;
|
||||
}
|
||||
|
||||
type SteeringReplayShape = "claude" | "codex";
|
||||
|
||||
interface MockQuestionOption {
|
||||
label: string;
|
||||
description?: string;
|
||||
@@ -188,6 +196,13 @@ function shouldEmitTurnFailure(prompt: AgentPromptInput): boolean {
|
||||
return /emit\s+(?:a\s+)?synthetic\s+turn\s+failure/i.test(promptToText(prompt));
|
||||
}
|
||||
|
||||
function parseSteeringReplayShape(prompt: AgentPromptInput): SteeringReplayShape | null {
|
||||
const match = /replay a (claude|codex)-shaped foreground shell tool call/i.exec(
|
||||
promptToText(prompt),
|
||||
);
|
||||
return match?.[1] === "claude" || match?.[1] === "codex" ? match[1] : null;
|
||||
}
|
||||
|
||||
function parseSettledAssistantImageMarkdown(prompt: AgentPromptInput): string | null {
|
||||
const match = /^emit settled assistant image markdown:\s*(!\[[^\]\r\n]*\]\(.+\))\s*$/i.exec(
|
||||
promptToText(prompt),
|
||||
@@ -648,6 +663,7 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
private readonly streamingAssistantIntervalMs: number;
|
||||
private readonly rewindError: string | null;
|
||||
private remainingPromptRejections: number;
|
||||
private remainingSteerFailures: number;
|
||||
|
||||
constructor(options: { config: AgentSessionConfig; sessionId: string; logger?: Logger }) {
|
||||
this.id = options.sessionId;
|
||||
@@ -681,6 +697,9 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
requestedPromptRejections > 0
|
||||
? requestedPromptRejections
|
||||
: 0;
|
||||
this.remainingSteerFailures = getPositiveFeatureInteger(
|
||||
options.config.featureValues?.mockSteerAmbiguousFailures,
|
||||
);
|
||||
}
|
||||
|
||||
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
|
||||
@@ -732,9 +751,12 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
const questionPrompt = parseMockQuestionPrompt(prompt);
|
||||
const structuredBranchName = parseStructuredBranchNamePrompt(prompt);
|
||||
const settledAssistantImageMarkdown = parseSettledAssistantImageMarkdown(prompt);
|
||||
const steeringReplayShape = parseSteeringReplayShape(prompt);
|
||||
const scheduleTurn = () => {
|
||||
if (shouldEmitTurnFailure(prompt)) {
|
||||
this.scheduleFailedTurn(turn);
|
||||
} else if (steeringReplayShape) {
|
||||
this.scheduleSteeringReplayTurn(turn, steeringReplayShape);
|
||||
} else if (this.streamingAssistantResponse !== null) {
|
||||
this.scheduleStreamingAssistantTurn(turn, this.streamingAssistantResponse);
|
||||
} else if (this.assistantResponse !== null) {
|
||||
@@ -928,6 +950,20 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
});
|
||||
}
|
||||
|
||||
async steerActiveTurn(
|
||||
_prompt: AgentPromptInput,
|
||||
options: SteerActiveTurnOptions,
|
||||
): Promise<SteerResult> {
|
||||
if (this.activeTurn?.turnId !== options.expectedTurnId) {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
if (this.remainingSteerFailures > 0) {
|
||||
this.remainingSteerFailures -= 1;
|
||||
throw new Error("Requested mock steer transport failure");
|
||||
}
|
||||
return { status: "accepted" };
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.interrupt();
|
||||
this.listeners.clear();
|
||||
@@ -1025,6 +1061,52 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
turn.timer.unref?.();
|
||||
}
|
||||
|
||||
private scheduleSteeringReplayTurn(turn: ActiveTurn, shape: SteeringReplayShape): void {
|
||||
turn.timer = setTimeout(() => {
|
||||
if (this.activeTurn !== turn) return;
|
||||
this.clearTurnTimer(turn);
|
||||
this.emitTurnStarted(turn);
|
||||
if (shape === "codex") {
|
||||
this.emitTimeline(turn.turnId, {
|
||||
type: "assistant_message",
|
||||
text: "Running the foreground command.",
|
||||
messageId: turn.assistantMessageId,
|
||||
});
|
||||
}
|
||||
const callId = `${turn.turnId}:steering-replay-shell`;
|
||||
const detail: ToolCallDetail = {
|
||||
type: "shell",
|
||||
command: "sleep 5",
|
||||
cwd: "/tmp/paseo-mock-load",
|
||||
};
|
||||
this.emitTimeline(
|
||||
turn.turnId,
|
||||
createToolCall({ callId, name: "bash", status: "running", detail }),
|
||||
);
|
||||
turn.timer = setTimeout(() => {
|
||||
if (this.activeTurn !== turn) return;
|
||||
this.clearTurnTimer(turn);
|
||||
this.emitTimeline(
|
||||
turn.turnId,
|
||||
createToolCall({
|
||||
callId,
|
||||
name: "bash",
|
||||
status: "completed",
|
||||
detail: { ...detail, output: "", exitCode: 0 },
|
||||
}),
|
||||
);
|
||||
this.emitTimeline(turn.turnId, {
|
||||
type: "assistant_message",
|
||||
text: "Foreground command completed after steering.",
|
||||
messageId: turn.assistantMessageId,
|
||||
});
|
||||
this.finishTurnWithText(turn, "Foreground command completed after steering.");
|
||||
}, 5_000);
|
||||
turn.timer.unref?.();
|
||||
}, 0);
|
||||
turn.timer.unref?.();
|
||||
}
|
||||
|
||||
private scheduleStressTurn(turn: ActiveTurn, stress: AgentStreamStressRequest): void {
|
||||
turn.timer = setTimeout(() => {
|
||||
this.emitStressTurn(turn, stress);
|
||||
|
||||
@@ -13,6 +13,7 @@ export type TimelineLimitDirection = "tail" | "before" | "after";
|
||||
|
||||
export interface TimelineProjectionEntry {
|
||||
item: AgentTimelineItem;
|
||||
turnId?: string;
|
||||
timestamp: string;
|
||||
seqStart: number;
|
||||
seqEnd: number;
|
||||
@@ -110,6 +111,7 @@ function mergeToolCallItems(
|
||||
function makeCanonicalEntries(rows: readonly AgentTimelineRow[]): WorkingEntry[] {
|
||||
return rows.map((row) => ({
|
||||
item: row.item,
|
||||
...(row.turnId ? { turnId: row.turnId } : {}),
|
||||
timestamp: row.timestamp,
|
||||
seqStart: row.seq,
|
||||
seqEnd: row.seq,
|
||||
@@ -136,7 +138,7 @@ function collapseToolLifecycle(entries: readonly WorkingEntry[]): WorkingEntry[]
|
||||
}
|
||||
|
||||
const existing = output[existingIndex];
|
||||
if (!existing || existing.item.type !== "tool_call") {
|
||||
if (!existing || existing.item.type !== "tool_call" || existing.turnId !== entry.turnId) {
|
||||
output.push(entry);
|
||||
continue;
|
||||
}
|
||||
@@ -169,7 +171,8 @@ function mergeReasoningChunks(entries: readonly WorkingEntry[]): WorkingEntry[]
|
||||
previous &&
|
||||
previous.item.type === "reasoning" &&
|
||||
entry.item.type === "reasoning" &&
|
||||
previous.seqEnd + 1 === entry.seqStart;
|
||||
previous.seqEnd + 1 === entry.seqStart &&
|
||||
previous.turnId === entry.turnId;
|
||||
|
||||
if (!shouldMerge || !previous) {
|
||||
output.push(entry);
|
||||
@@ -209,7 +212,8 @@ function mergeAssistantChunks(entries: readonly WorkingEntry[]): WorkingEntry[]
|
||||
previous &&
|
||||
previous.item.type === "assistant_message" &&
|
||||
entry.item.type === "assistant_message" &&
|
||||
previous.seqEnd + 1 === entry.seqStart;
|
||||
previous.seqEnd + 1 === entry.seqStart &&
|
||||
previous.turnId === entry.turnId;
|
||||
|
||||
if (!shouldMerge || !previous) {
|
||||
output.push(entry);
|
||||
|
||||
@@ -128,6 +128,7 @@ import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.
|
||||
import type { RequestedSpeechProviders } from "./speech/speech-types.js";
|
||||
import { createSpeechService } from "./speech/speech-runtime.js";
|
||||
import { AgentManager } from "./agent/agent-manager.js";
|
||||
import { FileAgentTimelineStore } from "./agent/file-agent-timeline-store.js";
|
||||
import { AgentStorage } from "./agent/agent-storage.js";
|
||||
import { attachAgentStoragePersistence } from "./persistence-hooks.js";
|
||||
import { createAgentMcpServer } from "./agent/mcp-server.js";
|
||||
@@ -841,6 +842,7 @@ export async function createPaseoDaemon(
|
||||
}
|
||||
|
||||
const agentStorage = new AgentStorage(config.agentStoragePath, logger);
|
||||
const timelineStore = new FileAgentTimelineStore(path.join(config.paseoHome, "agent-timelines"));
|
||||
const projectRegistry = new FileBackedProjectRegistry(
|
||||
path.join(config.paseoHome, "projects", "projects.json"),
|
||||
logger,
|
||||
@@ -889,6 +891,7 @@ export async function createPaseoDaemon(
|
||||
});
|
||||
const initialAgentManagerState = providerSnapshotManager.getAgentManagerProviderState();
|
||||
const agentManager = new AgentManager({
|
||||
durableTimelineStore: timelineStore,
|
||||
clients: initialAgentManagerState.clients,
|
||||
providerDefinitions: initialAgentManagerState.providerDefinitions,
|
||||
registry: agentStorage,
|
||||
|
||||
+322
-1
@@ -5,6 +5,7 @@ import path from "node:path";
|
||||
import pino from "pino";
|
||||
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import {
|
||||
canRunRealProvider,
|
||||
@@ -23,6 +24,55 @@ function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function within<T>(label: string, timeoutMs: number, operation: Promise<T>): Promise<T> {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
operation,
|
||||
new Promise<T>((_resolve, reject) => {
|
||||
timeout = setTimeout(
|
||||
() => reject(new Error(`Timed out after ${timeoutMs}ms: ${label}`)),
|
||||
timeoutMs,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function generateClientMessageId(): string {
|
||||
return `msg_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||
}
|
||||
|
||||
interface ObservedForegroundSleep {
|
||||
callId: string;
|
||||
turnId?: string;
|
||||
}
|
||||
|
||||
function getRunningClaudeSleep(
|
||||
message: SessionOutboundMessage,
|
||||
agentId: string,
|
||||
): ObservedForegroundSleep | null {
|
||||
if (
|
||||
message.type !== "agent_stream" ||
|
||||
message.payload.agentId !== agentId ||
|
||||
message.payload.event.type !== "timeline" ||
|
||||
message.payload.event.item.type !== "tool_call"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const tool = message.payload.event.item;
|
||||
if (
|
||||
tool.status !== "running" ||
|
||||
tool.detail.type !== "shell" ||
|
||||
!/\bsleep 5\b/.test(tool.detail.command)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { callId: tool.callId, turnId: message.payload.event.turnId };
|
||||
}
|
||||
|
||||
function hasRunningToolCall(messages: SessionOutboundMessage[], agentId: string): boolean {
|
||||
for (const m of messages) {
|
||||
if (
|
||||
@@ -38,6 +88,37 @@ function hasRunningToolCall(messages: SessionOutboundMessage[], agentId: string)
|
||||
return false;
|
||||
}
|
||||
|
||||
function isCapturedSleepCompletion(
|
||||
message: SessionOutboundMessage,
|
||||
agentId: string,
|
||||
callId: string,
|
||||
): boolean {
|
||||
return (
|
||||
message.type === "agent_stream" &&
|
||||
message.payload.agentId === agentId &&
|
||||
message.payload.event.type === "timeline" &&
|
||||
message.payload.event.item.type === "tool_call" &&
|
||||
message.payload.event.item.callId === callId &&
|
||||
message.payload.event.item.status === "completed"
|
||||
);
|
||||
}
|
||||
|
||||
function isCapturedSleepCancellation(
|
||||
message: SessionOutboundMessage,
|
||||
agentId: string,
|
||||
callId: string,
|
||||
): boolean {
|
||||
return (
|
||||
message.type === "agent_stream" &&
|
||||
message.payload.agentId === agentId &&
|
||||
message.payload.event.type === "timeline" &&
|
||||
message.payload.event.item.type === "tool_call" &&
|
||||
message.payload.event.item.callId === callId &&
|
||||
(message.payload.event.item.status === "canceled" ||
|
||||
message.payload.event.item.status === "failed")
|
||||
);
|
||||
}
|
||||
|
||||
function getAssistantTexts(messages: SessionOutboundMessage[], agentId: string): string[] {
|
||||
return messages
|
||||
.filter(
|
||||
@@ -165,13 +246,72 @@ async function waitForRunningToolCall(
|
||||
.filter((entry) => entry.item.type === "assistant_message")
|
||||
.slice(-5)
|
||||
.map((entry) => entry.item.text) ?? [];
|
||||
const snapshot = await client.fetchAgent({ agentId }).catch(() => null);
|
||||
const streamFailures = collector.messages
|
||||
.filter(
|
||||
(message) =>
|
||||
message.type === "agent_stream" &&
|
||||
message.payload.agentId === agentId &&
|
||||
(message.payload.event.type === "turn_failed" ||
|
||||
message.payload.event.type === "turn_canceled"),
|
||||
)
|
||||
.map((message) => message.payload.event);
|
||||
const permissionEvents = collector.messages
|
||||
.filter(
|
||||
(message) =>
|
||||
message.type === "agent_stream" &&
|
||||
message.payload.agentId === agentId &&
|
||||
message.payload.event.type === "permission_requested",
|
||||
)
|
||||
.map((message) => message.payload.event);
|
||||
throw new Error(
|
||||
`Timed out waiting for running tool call. Recent tool_calls=${JSON.stringify(recentToolCalls)} recent assistant text=${JSON.stringify(recentAssistantTexts)}`,
|
||||
`Timed out waiting for running tool call. lifecycle=${snapshot?.agent.status ?? "missing"} activeTurn=${snapshot?.agent.activeForegroundTurnId ?? "none"} recent tool_calls=${JSON.stringify(recentToolCalls)} recent assistant text=${JSON.stringify(recentAssistantTexts)} stream failures=${JSON.stringify(streamFailures)} permissions=${JSON.stringify(permissionEvents)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForRunningClaudeSleep(
|
||||
client: DaemonClient,
|
||||
collector: ReturnType<typeof createMessageCollector>,
|
||||
agentId: string,
|
||||
timeoutMs = 90_000,
|
||||
): Promise<ObservedForegroundSleep> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const observed = collector.messages
|
||||
.map((message) => getRunningClaudeSleep(message, agentId))
|
||||
.find((event): event is ObservedForegroundSleep => event !== null);
|
||||
if (observed) {
|
||||
return observed;
|
||||
}
|
||||
|
||||
// Projection is only diagnostic here; tool lifecycle rows are collapsed by fetch.
|
||||
const timeline = await client.fetchAgentTimeline(agentId, { limit: 100 }).catch(() => null);
|
||||
const assistantTexts =
|
||||
timeline?.entries
|
||||
.filter((entry) => entry.item.type === "assistant_message")
|
||||
.slice(-5)
|
||||
.map((entry) => entry.item.text) ?? [];
|
||||
const limitText = assistantTexts.find((text) => hasProviderLimitText(text));
|
||||
if (limitText) {
|
||||
throw new Error(`Claude provider rejected the run: ${limitText}`);
|
||||
}
|
||||
await sleep(50);
|
||||
}
|
||||
|
||||
const timeline = await client.fetchAgentTimeline(agentId, { limit: 100 }).catch(() => null);
|
||||
throw new Error(
|
||||
`Timed out waiting for Claude to report the live foreground sleep 5 call. Recent timeline=${JSON.stringify(timeline ? summarizeTimelineItems(timeline) : [])}`,
|
||||
);
|
||||
}
|
||||
|
||||
describe("daemon E2E (real claude) - send message during tool call", () => {
|
||||
let canRun = false;
|
||||
interface SteeringResources {
|
||||
cwd: string | null;
|
||||
daemon: Awaited<ReturnType<typeof createTestPaseoDaemon>> | null;
|
||||
client: DaemonClient | null;
|
||||
collector: ReturnType<typeof createMessageCollector> | null;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
canRun = await canRunRealProvider("claude");
|
||||
@@ -183,6 +323,187 @@ describe("daemon E2E (real claude) - send message during tool call", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("steers one active Claude turn without starting another", async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const resources: SteeringResources = {
|
||||
cwd: tmpCwd(),
|
||||
daemon: null,
|
||||
client: null,
|
||||
collector: null,
|
||||
};
|
||||
try {
|
||||
resources.daemon = await createTestPaseoDaemon({
|
||||
// Use the installed SDK's configured authentication so this regression
|
||||
// exercises the native streaming-input path.
|
||||
agentClients: { claude: new ClaudeAgentClient({ logger }) },
|
||||
logger,
|
||||
});
|
||||
resources.client = new DaemonClient({ url: `ws://127.0.0.1:${resources.daemon.port}/ws` });
|
||||
const { client, cwd } = resources;
|
||||
if (!client) throw new Error("Claude steering test client was not created");
|
||||
await within("connect Claude steering test client", 15_000, client.connect());
|
||||
await within(
|
||||
"subscribe Claude steering test client",
|
||||
15_000,
|
||||
client.fetchAgents({ subscribe: { subscriptionId: "steer" } }),
|
||||
);
|
||||
const agent = await within(
|
||||
"create Claude steering test agent",
|
||||
30_000,
|
||||
client.createAgent({
|
||||
cwd: cwd ?? process.cwd(),
|
||||
title: "claude-exact-turn-steer",
|
||||
...getRealProviderConfig("claude"),
|
||||
}),
|
||||
);
|
||||
resources.collector = createMessageCollector(client);
|
||||
await within(
|
||||
"submit Claude foreground sleep turn",
|
||||
30_000,
|
||||
client.sendAgentMessage(
|
||||
agent.id,
|
||||
[
|
||||
"Use the Bash tool.",
|
||||
"Run exactly: sleep 5. Run it in the foreground. Do not finish until a later user message arrives.",
|
||||
"After that message arrives, use the Bash tool again and run exactly: printf SECOND_BOUNDARY. Then reply exactly: STEERED_SAME_TURN.",
|
||||
"Do not use a background task.",
|
||||
].join(" "),
|
||||
{ messageId: generateClientMessageId() },
|
||||
),
|
||||
);
|
||||
const foregroundSleep = await within(
|
||||
"wait for Claude to begin the live foreground sleep tool",
|
||||
90_000,
|
||||
waitForRunningClaudeSleep(client, resources.collector, agent.id, 80_000),
|
||||
);
|
||||
await within(
|
||||
"confirm Claude remains active at the first tool boundary",
|
||||
15_000,
|
||||
client.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running", 10_000),
|
||||
);
|
||||
const initialTurnStarts = resources.collector.messages.filter(
|
||||
(message) =>
|
||||
message.type === "agent_stream" &&
|
||||
message.payload.agentId === agent.id &&
|
||||
message.payload.event.type === "turn_started",
|
||||
);
|
||||
expect(initialTurnStarts).toHaveLength(1);
|
||||
const initialTurnId = initialTurnStarts[0]?.payload.event.turnId;
|
||||
expect(initialTurnId).toEqual(expect.any(String));
|
||||
expect(foregroundSleep.turnId).toBe(initialTurnId);
|
||||
const steeringMessageId = generateClientMessageId();
|
||||
const messagesBeforeSteer = resources.collector.messages.length;
|
||||
await within(
|
||||
"submit Claude active-turn steer",
|
||||
30_000,
|
||||
client.sendAgentMessage(agent.id, "hello", {
|
||||
messageId: steeringMessageId,
|
||||
activeTurnBehavior: "steer",
|
||||
}),
|
||||
);
|
||||
const finish = await within(
|
||||
"wait for steered Claude turn to finish",
|
||||
150_000,
|
||||
client.waitForFinish(agent.id, 140_000),
|
||||
);
|
||||
expect(finish.status).toBe("idle");
|
||||
const postSteerMessages = resources.collector.messages.slice(messagesBeforeSteer);
|
||||
const turnStarts = postSteerMessages.filter(
|
||||
(message) =>
|
||||
message.type === "agent_stream" &&
|
||||
message.payload.agentId === agent.id &&
|
||||
message.payload.event.type === "turn_started",
|
||||
);
|
||||
expect(turnStarts).toHaveLength(0);
|
||||
expect(
|
||||
postSteerMessages.filter(
|
||||
(message) =>
|
||||
message.type === "agent_stream" &&
|
||||
message.payload.agentId === agent.id &&
|
||||
message.payload.event.type === "turn_canceled",
|
||||
),
|
||||
).toHaveLength(0);
|
||||
expect(
|
||||
postSteerMessages.filter(
|
||||
(message) =>
|
||||
message.type === "agent_stream" &&
|
||||
message.payload.agentId === agent.id &&
|
||||
message.payload.event.type === "turn_completed",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
postSteerMessages.some((message) =>
|
||||
isCapturedSleepCompletion(message, agent.id, foregroundSleep.callId),
|
||||
),
|
||||
"the exact live sleep 5 call must complete after hello is submitted",
|
||||
).toBe(true);
|
||||
expect(
|
||||
postSteerMessages.some((message) =>
|
||||
isCapturedSleepCancellation(message, agent.id, foregroundSleep.callId),
|
||||
),
|
||||
"the exact live sleep 5 call must not be canceled or fail after hello",
|
||||
).toBe(false);
|
||||
const secondBoundaryEvents = postSteerMessages.filter(
|
||||
(message) =>
|
||||
message.type === "agent_stream" &&
|
||||
message.payload.agentId === agent.id &&
|
||||
message.payload.event.type === "timeline" &&
|
||||
message.payload.event.item.type === "tool_call" &&
|
||||
message.payload.event.item.detail.type === "shell" &&
|
||||
/\bprintf\s+SECOND_BOUNDARY\b/.test(message.payload.event.item.detail.command),
|
||||
);
|
||||
expect(
|
||||
secondBoundaryEvents.some(
|
||||
(message) =>
|
||||
message.payload.event.type === "timeline" &&
|
||||
message.payload.event.item.type === "tool_call" &&
|
||||
message.payload.event.item.status === "completed",
|
||||
),
|
||||
"hello must drive a second completed tool call in the same active loop",
|
||||
).toBe(true);
|
||||
expect(
|
||||
secondBoundaryEvents.every(
|
||||
(message) =>
|
||||
message.payload.event.type === "timeline" &&
|
||||
message.payload.event.turnId === initialTurnId,
|
||||
),
|
||||
"both Claude tool boundaries must retain the original turn ID",
|
||||
).toBe(true);
|
||||
const timeline = await within(
|
||||
"fetch steered Claude timeline",
|
||||
15_000,
|
||||
client.fetchAgentTimeline(agent.id, { limit: 100 }),
|
||||
);
|
||||
const assistantText = timeline.entries
|
||||
.filter((entry) => entry.item.type === "assistant_message")
|
||||
.map(
|
||||
(entry) => (entry.item as Extract<AgentTimelineItem, { type: "assistant_message" }>).text,
|
||||
)
|
||||
.join("\\n");
|
||||
const steeringRows = timeline.entries.filter(
|
||||
(entry) => entry.item.type === "user_message" && entry.item.text === "hello",
|
||||
);
|
||||
expect(steeringRows).toHaveLength(1);
|
||||
expect(steeringRows[0]?.item).toMatchObject({
|
||||
messageId: steeringMessageId,
|
||||
clientMessageId: steeringMessageId,
|
||||
});
|
||||
expect(steeringRows[0]?.turnId).toBe(initialTurnId);
|
||||
expect(assistantText).toContain("STEERED_SAME_TURN");
|
||||
} finally {
|
||||
const cleanup = await Promise.allSettled([
|
||||
Promise.resolve(resources.collector?.unsubscribe()),
|
||||
resources.client?.close() ?? Promise.resolve(),
|
||||
resources.daemon?.close() ?? Promise.resolve(),
|
||||
]);
|
||||
if (resources.cwd) rmSync(resources.cwd, { recursive: true, force: true });
|
||||
const failures = cleanup.flatMap((result) =>
|
||||
result.status === "rejected" ? [result.reason] : [],
|
||||
);
|
||||
expect(failures, "Claude steering E2E cleanup failures").toEqual([]);
|
||||
}
|
||||
}, 210_000);
|
||||
|
||||
test("sending a message while a tool call is running replaces the turn without error, idle flash, or autonomous fallback", async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const cwd = tmpCwd();
|
||||
|
||||
+260
-22
@@ -5,15 +5,12 @@ import path from "node:path";
|
||||
import pino from "pino";
|
||||
|
||||
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
|
||||
import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createMessageCollector } from "../test-utils/message-collector.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import type { SessionOutboundMessage } from "../messages.js";
|
||||
import {
|
||||
canRunRealProvider,
|
||||
createRealProviderClients,
|
||||
getRealProviderConfig,
|
||||
} from "./real-provider-test-config.js";
|
||||
import { canRunRealProvider } from "./real-provider-test-config.js";
|
||||
|
||||
function tmpCwd(): string {
|
||||
return mkdtempSync(path.join(tmpdir(), "daemon-real-codex-tool-interrupt-"));
|
||||
@@ -23,6 +20,23 @@ function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function within<T>(label: string, timeoutMs: number, operation: Promise<T>): Promise<T> {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
operation,
|
||||
new Promise<T>((_resolve, reject) => {
|
||||
timeout = setTimeout(
|
||||
() => reject(new Error(`Timed out after ${timeoutMs}ms: ${label}`)),
|
||||
timeoutMs,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function generateClientMessageId(): string {
|
||||
return `msg_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||
}
|
||||
@@ -102,24 +116,84 @@ function summarizeTimelineItems(timeline: Awaited<ReturnType<DaemonClient["fetch
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForAssistantWaitingOnSleep(
|
||||
interface ObservedForegroundSleep {
|
||||
callId: string;
|
||||
}
|
||||
|
||||
function getRunningCodexSleep(
|
||||
message: SessionOutboundMessage,
|
||||
agentId: string,
|
||||
command: RegExp,
|
||||
): ObservedForegroundSleep | null {
|
||||
if (
|
||||
message.type !== "agent_stream" ||
|
||||
message.payload.agentId !== agentId ||
|
||||
message.payload.event.type !== "timeline" ||
|
||||
message.payload.event.item.type !== "tool_call"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const tool = message.payload.event.item;
|
||||
if (
|
||||
tool.status !== "running" ||
|
||||
tool.detail.type !== "shell" ||
|
||||
!command.test(tool.detail.command)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { callId: tool.callId };
|
||||
}
|
||||
|
||||
function isCapturedSleepCompletion(
|
||||
message: SessionOutboundMessage,
|
||||
agentId: string,
|
||||
callId: string,
|
||||
): boolean {
|
||||
return (
|
||||
message.type === "agent_stream" &&
|
||||
message.payload.agentId === agentId &&
|
||||
message.payload.event.type === "timeline" &&
|
||||
message.payload.event.item.type === "tool_call" &&
|
||||
message.payload.event.item.callId === callId &&
|
||||
message.payload.event.item.status === "completed"
|
||||
);
|
||||
}
|
||||
|
||||
function isCapturedSleepCancellation(
|
||||
message: SessionOutboundMessage,
|
||||
agentId: string,
|
||||
callId: string,
|
||||
): boolean {
|
||||
return (
|
||||
message.type === "agent_stream" &&
|
||||
message.payload.agentId === agentId &&
|
||||
message.payload.event.type === "timeline" &&
|
||||
message.payload.event.item.type === "tool_call" &&
|
||||
message.payload.event.item.callId === callId &&
|
||||
(message.payload.event.item.status === "canceled" ||
|
||||
message.payload.event.item.status === "failed")
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForRunningCodexSleep(
|
||||
client: DaemonClient,
|
||||
collector: ReturnType<typeof createMessageCollector>,
|
||||
agentId: string,
|
||||
timeoutMs = 75_000,
|
||||
): Promise<void> {
|
||||
command = /\bsleep 5\b/,
|
||||
): Promise<ObservedForegroundSleep> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const timeline = await client.fetchAgentTimeline(agentId, { limit: 100 });
|
||||
assertNoProviderLimit(timeline);
|
||||
const assistantText = timeline.entries
|
||||
.filter((entry) => entry.item.type === "assistant_message")
|
||||
.map((entry) => entry.item.text)
|
||||
.join("\n");
|
||||
if (/still running|waiting/i.test(assistantText) && !/`done`|\bdone\b/i.test(assistantText)) {
|
||||
return;
|
||||
const observed = collector.messages
|
||||
.map((message) => getRunningCodexSleep(message, agentId, command))
|
||||
.find((event): event is ObservedForegroundSleep => event !== null);
|
||||
if (observed) {
|
||||
return observed;
|
||||
}
|
||||
|
||||
await sleep(500);
|
||||
// Timeline fetches are diagnostic only: projection collapses the lifecycle row.
|
||||
const timeline = await client.fetchAgentTimeline(agentId, { limit: 100 }).catch(() => null);
|
||||
if (timeline) assertNoProviderLimit(timeline);
|
||||
await sleep(50);
|
||||
}
|
||||
|
||||
const timeline = await client.fetchAgentTimeline(agentId, { limit: 100 }).catch(() => null);
|
||||
@@ -144,6 +218,12 @@ async function waitForAssistantWaitingOnSleep(
|
||||
|
||||
describe("daemon E2E (real codex) - send message during tool call", () => {
|
||||
let canRun = false;
|
||||
interface SteeringResources {
|
||||
cwd: string | null;
|
||||
daemon: Awaited<ReturnType<typeof createTestPaseoDaemon>> | null;
|
||||
client: DaemonClient | null;
|
||||
collector: ReturnType<typeof createMessageCollector> | null;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
canRun = await canRunRealProvider("codex");
|
||||
@@ -155,11 +235,163 @@ describe("daemon E2E (real codex) - send message during tool call", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("steers one active Codex turn without starting another", async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const resources: SteeringResources = {
|
||||
cwd: tmpCwd(),
|
||||
daemon: null,
|
||||
client: null,
|
||||
collector: null,
|
||||
};
|
||||
try {
|
||||
resources.daemon = await createTestPaseoDaemon({
|
||||
// Use the installed app-server so this regression exercises the native
|
||||
// steering method rather than the broad provider smoke-test path.
|
||||
agentClients: { codex: new CodexAppServerAgentClient(logger) },
|
||||
logger,
|
||||
});
|
||||
resources.client = new DaemonClient({ url: `ws://127.0.0.1:${resources.daemon.port}/ws` });
|
||||
const { client, cwd } = resources;
|
||||
if (!client) throw new Error("Codex steering test client was not created");
|
||||
await within("connect steering test client", 15_000, client.connect());
|
||||
await within(
|
||||
"subscribe steering test client",
|
||||
15_000,
|
||||
client.fetchAgents({ subscribe: { subscriptionId: "steer" } }),
|
||||
);
|
||||
const agent = await within(
|
||||
"create Codex steering test agent",
|
||||
30_000,
|
||||
client.createAgent({
|
||||
cwd: cwd ?? process.cwd(),
|
||||
title: "codex-exact-turn-steer",
|
||||
provider: "codex",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "low",
|
||||
modeId: "full-access",
|
||||
}),
|
||||
);
|
||||
resources.collector = createMessageCollector(client);
|
||||
await within(
|
||||
"submit Codex foreground sleep turn",
|
||||
30_000,
|
||||
client.sendAgentMessage(
|
||||
agent.id,
|
||||
"Use the shell tool to run exactly: sleep 5. Run it in the foreground. Do not finish until a later user message arrives; after it arrives, reply exactly: STEERED_SAME_TURN.",
|
||||
{ messageId: generateClientMessageId() },
|
||||
),
|
||||
);
|
||||
const foregroundSleep = await within(
|
||||
"wait for live Codex foreground sleep tool",
|
||||
90_000,
|
||||
waitForRunningCodexSleep(client, resources.collector, agent.id, 80_000),
|
||||
);
|
||||
await within(
|
||||
"confirm Codex remains active at the first tool boundary",
|
||||
15_000,
|
||||
client.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running", 10_000),
|
||||
);
|
||||
const initialTurnStarts = resources.collector.messages.filter(
|
||||
(message) =>
|
||||
message.type === "agent_stream" &&
|
||||
message.payload.agentId === agent.id &&
|
||||
message.payload.event.type === "turn_started",
|
||||
);
|
||||
expect(initialTurnStarts).toHaveLength(1);
|
||||
const initialTurnId = initialTurnStarts[0]?.payload.event.turnId;
|
||||
expect(initialTurnId).toEqual(expect.any(String));
|
||||
const steeringMessageId = generateClientMessageId();
|
||||
const messagesBeforeSteer = resources.collector.messages.length;
|
||||
await within(
|
||||
"submit Codex active-turn steer",
|
||||
30_000,
|
||||
client.sendAgentMessage(agent.id, "hello", {
|
||||
messageId: steeringMessageId,
|
||||
activeTurnBehavior: "steer",
|
||||
}),
|
||||
);
|
||||
const finish = await within(
|
||||
"wait for steered Codex turn to finish",
|
||||
150_000,
|
||||
client.waitForFinish(agent.id, 140_000),
|
||||
);
|
||||
expect(finish.status).toBe("idle");
|
||||
const postSteerMessages = resources.collector.messages.slice(messagesBeforeSteer);
|
||||
const turnStarts = postSteerMessages.filter(
|
||||
(message) =>
|
||||
message.type === "agent_stream" &&
|
||||
message.payload.agentId === agent.id &&
|
||||
message.payload.event.type === "turn_started",
|
||||
);
|
||||
expect(turnStarts).toHaveLength(0);
|
||||
expect(
|
||||
postSteerMessages.filter(
|
||||
(message) =>
|
||||
message.type === "agent_stream" &&
|
||||
message.payload.agentId === agent.id &&
|
||||
message.payload.event.type === "turn_canceled",
|
||||
),
|
||||
).toHaveLength(0);
|
||||
expect(
|
||||
postSteerMessages.filter(
|
||||
(message) =>
|
||||
message.type === "agent_stream" &&
|
||||
message.payload.agentId === agent.id &&
|
||||
message.payload.event.type === "turn_completed",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
postSteerMessages.some((message) =>
|
||||
isCapturedSleepCompletion(message, agent.id, foregroundSleep.callId),
|
||||
),
|
||||
"the exact live sleep 5 call must complete after hello is submitted",
|
||||
).toBe(true);
|
||||
expect(
|
||||
postSteerMessages.some((message) =>
|
||||
isCapturedSleepCancellation(message, agent.id, foregroundSleep.callId),
|
||||
),
|
||||
"the exact live sleep 5 call must not be canceled or fail after hello",
|
||||
).toBe(false);
|
||||
const timeline = await within(
|
||||
"fetch steered Codex timeline",
|
||||
15_000,
|
||||
client.fetchAgentTimeline(agent.id, { limit: 100 }),
|
||||
);
|
||||
const assistantText = timeline.entries
|
||||
.filter((entry) => entry.item.type === "assistant_message")
|
||||
.map(
|
||||
(entry) => (entry.item as Extract<AgentTimelineItem, { type: "assistant_message" }>).text,
|
||||
)
|
||||
.join("\n");
|
||||
const steeringRows = timeline.entries.filter(
|
||||
(entry) => entry.item.type === "user_message" && entry.item.text === "hello",
|
||||
);
|
||||
expect(steeringRows).toHaveLength(1);
|
||||
expect(steeringRows[0]?.item).toMatchObject({
|
||||
messageId: steeringMessageId,
|
||||
clientMessageId: steeringMessageId,
|
||||
});
|
||||
expect(steeringRows[0]?.turnId).toBe(initialTurnId);
|
||||
expect(assistantText).toContain("STEERED_SAME_TURN");
|
||||
} finally {
|
||||
const cleanup = await Promise.allSettled([
|
||||
Promise.resolve(resources.collector?.unsubscribe()),
|
||||
resources.client?.close() ?? Promise.resolve(),
|
||||
resources.daemon?.close() ?? Promise.resolve(),
|
||||
]);
|
||||
if (resources.cwd) rmSync(resources.cwd, { recursive: true, force: true });
|
||||
const failures = cleanup.flatMap((result) =>
|
||||
result.status === "rejected" ? [result.reason] : [],
|
||||
);
|
||||
expect(failures, "Codex steering E2E cleanup failures").toEqual([]);
|
||||
}
|
||||
}, 210_000);
|
||||
|
||||
test("does not emit an idle agent_update between UI send and the replacement Codex turn", async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const cwd = tmpCwd();
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: createRealProviderClients(["codex"], logger),
|
||||
agentClients: { codex: new CodexAppServerAgentClient(logger) },
|
||||
logger,
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
@@ -172,7 +404,10 @@ describe("daemon E2E (real codex) - send message during tool call", () => {
|
||||
const agent = await client.createAgent({
|
||||
cwd,
|
||||
title: "codex-tool-interrupt-repro",
|
||||
...getRealProviderConfig("codex"),
|
||||
provider: "codex",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "low",
|
||||
modeId: "full-access",
|
||||
});
|
||||
|
||||
collector = createMessageCollector(client);
|
||||
@@ -186,7 +421,7 @@ describe("daemon E2E (real codex) - send message during tool call", () => {
|
||||
(snapshot) => snapshot.status === "running",
|
||||
60_000,
|
||||
);
|
||||
await waitForAssistantWaitingOnSleep(client, agent.id);
|
||||
await waitForRunningCodexSleep(client, collector, agent.id, 75_000, /\bsleep 60\b/);
|
||||
|
||||
collector.clear();
|
||||
|
||||
@@ -235,7 +470,7 @@ describe("daemon E2E (real codex) - send message during tool call", () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const cwd = tmpCwd();
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: createRealProviderClients(["codex"], logger),
|
||||
agentClients: { codex: new CodexAppServerAgentClient(logger) },
|
||||
logger,
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
@@ -248,7 +483,10 @@ describe("daemon E2E (real codex) - send message during tool call", () => {
|
||||
const agent = await client.createAgent({
|
||||
cwd,
|
||||
title: "codex-quick-follow-up-repro",
|
||||
...getRealProviderConfig("codex"),
|
||||
provider: "codex",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "low",
|
||||
modeId: "full-access",
|
||||
});
|
||||
|
||||
collector = createMessageCollector(client);
|
||||
|
||||
@@ -6621,21 +6621,26 @@ export class Session {
|
||||
hasOlder: selectedTimeline.hasOlder,
|
||||
hasNewer: selectedTimeline.hasNewer,
|
||||
...(msg.mergeWindow === true ? { mergeWindow: true } : {}),
|
||||
entries: selectedTimeline.entries.map((entry) => ({
|
||||
provider: snapshot.provider,
|
||||
item: entry.item,
|
||||
timestamp: entry.timestamp,
|
||||
seqStart: entry.seqStart,
|
||||
seqEnd: entry.seqEnd,
|
||||
sourceSeqRanges: entry.sourceSeqRanges,
|
||||
collapsed: (
|
||||
source
|
||||
? this.supportsForSource(CLIENT_CAPS.reasoningMergeEnum, source)
|
||||
: this.supports(CLIENT_CAPS.reasoningMergeEnum)
|
||||
)
|
||||
? entry.collapsed
|
||||
: entry.collapsed.filter((value) => value !== "reasoning_merge"),
|
||||
})),
|
||||
entries: selectedTimeline.entries.map((entry) => {
|
||||
const payloadEntry = {
|
||||
provider: snapshot.provider,
|
||||
item: entry.item,
|
||||
timestamp: entry.timestamp,
|
||||
seqStart: entry.seqStart,
|
||||
seqEnd: entry.seqEnd,
|
||||
sourceSeqRanges: entry.sourceSeqRanges,
|
||||
turnId: undefined as string | undefined,
|
||||
collapsed: (
|
||||
source
|
||||
? this.supportsForSource(CLIENT_CAPS.reasoningMergeEnum, source)
|
||||
: this.supports(CLIENT_CAPS.reasoningMergeEnum)
|
||||
)
|
||||
? entry.collapsed
|
||||
: entry.collapsed.filter((value) => value !== "reasoning_merge"),
|
||||
};
|
||||
payloadEntry.turnId = entry.turnId;
|
||||
return payloadEntry;
|
||||
}),
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
@@ -6904,11 +6909,12 @@ export class Session {
|
||||
{
|
||||
agentId,
|
||||
messageId: msg.messageId,
|
||||
activeTurnBehavior: msg.activeTurnBehavior,
|
||||
textPrefix: msg.text.slice(0, 80),
|
||||
},
|
||||
"agent.session.send_agent_message",
|
||||
);
|
||||
let dispatchResult: { outOfBand: boolean };
|
||||
let dispatchResult: { disposition: "out_of_band" | "steered" | "turn_started" };
|
||||
try {
|
||||
dispatchResult = await sendPromptToAgent({
|
||||
agentManager: this.agentManager,
|
||||
@@ -6916,6 +6922,7 @@ export class Session {
|
||||
agentId,
|
||||
prompt,
|
||||
messageId: msg.messageId,
|
||||
activeTurnBehavior: msg.activeTurnBehavior ?? "interrupt",
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -6933,7 +6940,7 @@ export class Session {
|
||||
return;
|
||||
}
|
||||
|
||||
if (dispatchResult.outOfBand) {
|
||||
if (dispatchResult.disposition !== "turn_started") {
|
||||
this.emit({
|
||||
type: "send_agent_message_response",
|
||||
payload: {
|
||||
|
||||
@@ -419,6 +419,32 @@ describe("wire compatibility", () => {
|
||||
expect(currentParsed.payload.entries[0]?.collapsed).toContain("reasoning_merge");
|
||||
});
|
||||
|
||||
test("carries canonical turn IDs to new clients while legacy schemas ignore them", async () => {
|
||||
const response = await emitTimelineResponse({
|
||||
rows: [
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: "2026-05-02T00:00:00.000Z",
|
||||
turnId: "turn-1",
|
||||
item: { type: "user_message", text: "prompt", clientMessageId: "message-1" },
|
||||
},
|
||||
{
|
||||
seq: 2,
|
||||
timestamp: "2026-05-02T00:00:01.000Z",
|
||||
turnId: "turn-1",
|
||||
item: { type: "assistant_message", text: "done" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(FetchAgentTimelineResponseMessageSchema.parse(response).payload.entries).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ turnId: "turn-1" })]),
|
||||
);
|
||||
expect(LegacyFetchAgentTimelineResponseMessageSchema.parse(response).payload.entries).toEqual(
|
||||
expect.arrayContaining([expect.not.objectContaining({ turnId: expect.anything() })]),
|
||||
);
|
||||
});
|
||||
|
||||
test("legacy worktree request shape normalizes to the same internal input as the new shape", async () => {
|
||||
const workflow = new InMemoryWorktreeWorkflow();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user