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:
Mohamed Boudra
2026-08-16 13:58:40 +02:00
committed by GitHub
parent 16120ebff1
commit 42245d139a
69 changed files with 5203 additions and 269 deletions
+58 -1
View File
@@ -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({
+15 -8
View File
@@ -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",
+4 -1
View File
@@ -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;
}
+67
View File
@@ -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();
+9 -2
View File
@@ -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;
}
}
+28 -11
View File
@@ -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
+4 -3
View File
@@ -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,
}),
+9 -1
View File
@@ -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({
+2 -1
View File
@@ -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;
+33
View File
@@ -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 (
+4
View File
@@ -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: "طابور",
},
},
+4
View File
@@ -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",
},
},
+4
View File
@@ -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",
},
},
+4
View File
@@ -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",
},
},
+4
View File
@@ -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: "キュー",
},
},
+4
View File
@@ -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: "대기열",
},
},
+4
View File
@@ -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",
},
},
+4
View File
@@ -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: "Очередь",
},
},
+3
View File
@@ -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) {
+2 -4
View File
@@ -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),
}));
+6 -3
View File
@@ -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) {
+71
View File
@@ -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({
+94 -12
View File
@@ -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