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