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:
@@ -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;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user