Reconcile optimistic user messages by client id

This commit is contained in:
murashit 2026-06-03 09:37:36 +09:00
parent a804bca4b7
commit 0480daf4cf
4 changed files with 165 additions and 9 deletions

View file

@ -378,16 +378,24 @@ function displayItemsWithPendingPromptSubmitHooks(state: ChatState, turnId: stri
function reconciledCompletedTurnItems(state: ChatState, turn: Turn): readonly DisplayItem[] {
const turnItems = displayItemsFromTurns([turn]);
if (turnItems.length === 0) return state.displayItems;
const serverUserTexts = new Set(turnItems.filter(isUserMessage).map((item) => item.text));
let mergedTurnItems = state.displayItems
const serverUserMessages = turnItems.filter(isUserMessage);
const serverUserClientIds = new Set(serverUserMessages.map((item) => item.clientId).filter(isString));
const serverUserMessagesByClientId = new Map(
serverUserMessages.flatMap((item) => (item.clientId ? ([[item.clientId, item]] as const) : [])),
);
const serverUserFallbackTexts = serverUserClientIds.size > 0 ? new Set<string>() : new Set(serverUserMessages.map((item) => item.text));
const stateDisplayItems = state.displayItems.map(
(item) => serverUserMessageForOptimisticItem(item, serverUserMessagesByClientId) ?? item,
);
let mergedTurnItems = stateDisplayItems
.filter((item) => item.turnId === turn.id)
.filter((item) => !isOptimisticUserMessage(item, serverUserTexts));
.filter((item) => !isOptimisticUserMessage(item, serverUserClientIds, serverUserFallbackTexts));
for (const item of turnItems) {
mergedTurnItems = upsertDisplayItem(mergedTurnItems, item);
}
const retainedItems = state.displayItems
const retainedItems = stateDisplayItems
.filter((item) => item.turnId !== turn.id)
.filter((item) => !isOptimisticUserMessage(item, serverUserTexts));
.filter((item) => !isOptimisticUserMessage(item, serverUserClientIds, serverUserFallbackTexts));
return [...retainedItems, ...mergedTurnItems];
}
@ -417,8 +425,25 @@ function isUserMessage(item: DisplayItem): item is MessageDisplayItem & { role:
return item.kind === "message" && item.role === "user";
}
function isOptimisticUserMessage(item: DisplayItem, serverUserTexts: Set<string>): boolean {
return isUserMessage(item) && (item.id.startsWith("local-user-") || item.id.startsWith("local-steer-")) && serverUserTexts.has(item.text);
function serverUserMessageForOptimisticItem(
item: DisplayItem,
serverUserMessagesByClientId: ReadonlyMap<string, MessageDisplayItem & { role: "user" }>,
): (MessageDisplayItem & { role: "user" }) | null {
if (!isUserMessage(item) || !isLocalUserMessageId(item.id)) return null;
return serverUserMessagesByClientId.get(item.id) ?? null;
}
function isOptimisticUserMessage(item: DisplayItem, serverUserClientIds: Set<string>, serverUserFallbackTexts: Set<string>): boolean {
if (!isUserMessage(item) || !isLocalUserMessageId(item.id)) return false;
return serverUserClientIds.has(item.id) || serverUserFallbackTexts.has(item.text);
}
function isLocalUserMessageId(id: string): boolean {
return id.startsWith("local-user-") || id.startsWith("local-steer-");
}
function isString(value: string | null | undefined): value is string {
return typeof value === "string";
}
function systemMessagePlan(message: { id: string; text: string }): ChatNotificationPlan {

View file

@ -107,6 +107,7 @@ function userMessageDisplayItem(item: UserMessageItem, turnId?: string): Display
copyText: referencedThread.text,
referencedThread: referencedThread.reference,
...definedProp("turnId", turnId),
...definedProp("clientId", item.clientId),
itemId: item.id,
markdown: true,
...(mentionedFiles.length > 0 ? { mentionedFiles } : {}),
@ -119,6 +120,7 @@ function userMessageDisplayItem(item: UserMessageItem, turnId?: string): Display
text: displayText,
copyText: text,
...definedProp("turnId", turnId),
...definedProp("clientId", item.clientId),
itemId: item.id,
markdown: true,
...(mentionedFiles.length > 0 ? { mentionedFiles } : {}),

View file

@ -40,6 +40,7 @@ export interface DisplayDetailSection {
export interface MessageDisplayItem extends DisplayBase {
kind: "message";
role: "user" | "assistant";
clientId?: string;
copyText?: string;
referencedThread?: ReferencedThreadDisplay;
mentionedFiles?: DisplayFileMention[];

View file

@ -536,14 +536,14 @@ describe("ChatController", () => {
error: null,
itemsView: "full",
items: [
{ type: "userMessage", id: "u1", clientId: null, content: [{ type: "text", text: "hello", text_elements: [] }] },
{ type: "userMessage", id: "u1", clientId: "local-user-1", content: [{ type: "text", text: "hello", text_elements: [] }] },
{ type: "agentMessage", id: "a1", text: "done", phase: "final_answer", memoryCitation: null },
],
},
},
} satisfies Extract<ServerNotification, { method: "turn/completed" }>);
expect(state.displayItems.map((item) => item.id)).toEqual(["hook-hook-1-1", "u1", "a1"]);
expect(state.displayItems.map((item) => item.id)).toEqual(["u1", "hook-hook-1-1", "a1"]);
expect(state.displayItems).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "u1", text: "hello", turnId: "turn-active" }),
@ -1044,6 +1044,134 @@ describe("ChatController", () => {
expect(state.displayItems.some((item) => item.id === "local-user-1")).toBe(false);
});
it("reconciles optimistic user echoes by client id before falling back to text only when client ids are absent", () => {
const state = createChatState();
state.activeThreadId = "thread-active";
state.turnLifecycle = { kind: "running", turnId: "turn-active" };
state.displayItems = [
{ id: "local-user-1", kind: "message", role: "user", text: "same text", turnId: "turn-active", markdown: true },
{ id: "local-steer-2", kind: "message", role: "user", text: "same text", turnId: "turn-active", markdown: true },
{ id: "local-user-2", kind: "message", role: "user", text: "same text", turnId: "turn-other", markdown: true },
];
const controller = controllerForState(state);
controller.handleNotification({
method: "turn/completed",
params: {
threadId: "thread-active",
turn: {
id: "turn-active",
status: "completed",
error: null,
startedAt: null,
completedAt: null,
durationMs: null,
itemsView: "full",
items: [
{
type: "userMessage",
id: "u1",
clientId: "local-user-1",
content: [{ type: "text", text: "same text", text_elements: [] }],
},
{ type: "agentMessage", id: "a1", text: "done", phase: "final_answer", memoryCitation: null },
],
},
},
} satisfies Extract<ServerNotification, { method: "turn/completed" }>);
expect(state.displayItems).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "u1", clientId: "local-user-1", text: "same text" }),
expect.objectContaining({ id: "local-steer-2", text: "same text" }),
expect.objectContaining({ id: "local-user-2", text: "same text" }),
]),
);
expect(state.displayItems.some((item) => item.id === "local-user-1")).toBe(false);
const legacyState = createChatState();
legacyState.activeThreadId = "thread-active";
legacyState.turnLifecycle = { kind: "running", turnId: "turn-active" };
legacyState.displayItems = [
{ id: "local-user-legacy", kind: "message", role: "user", text: "legacy text", turnId: "turn-active", markdown: true },
];
const legacyController = controllerForState(legacyState);
legacyController.handleNotification({
method: "turn/completed",
params: {
threadId: "thread-active",
turn: {
id: "turn-active",
status: "completed",
error: null,
startedAt: null,
completedAt: null,
durationMs: null,
itemsView: "full",
items: [
{ type: "userMessage", id: "legacy-u1", clientId: null, content: [{ type: "text", text: "legacy text", text_elements: [] }] },
],
},
},
} satisfies Extract<ServerNotification, { method: "turn/completed" }>);
expect(legacyState.displayItems).toEqual([expect.objectContaining({ id: "legacy-u1", text: "legacy text" })]);
expect(legacyState.displayItems.some((item) => item.id === "local-user-legacy")).toBe(false);
});
it("keeps the observed steer message order when completed turns reconcile by client id", () => {
const state = createChatState();
state.activeThreadId = "thread-active";
state.turnLifecycle = { kind: "running", turnId: "turn-active" };
state.displayItems = [
{ id: "local-user-1", kind: "message", role: "user", text: "start", turnId: "turn-active", markdown: true },
{
id: "a1",
itemId: "a1",
kind: "message",
role: "assistant",
text: "first partial",
turnId: "turn-active",
markdown: true,
},
{ id: "local-steer-1", kind: "message", role: "user", text: "steer", turnId: "turn-active", markdown: true },
];
const controller = controllerForState(state);
controller.handleNotification({
method: "turn/completed",
params: {
threadId: "thread-active",
turn: {
id: "turn-active",
status: "completed",
error: null,
startedAt: null,
completedAt: null,
durationMs: null,
itemsView: "full",
items: [
{ type: "userMessage", id: "u1", clientId: "local-user-1", content: [{ type: "text", text: "start", text_elements: [] }] },
{ type: "agentMessage", id: "a1", text: "first done", phase: "final_answer", memoryCitation: null },
{ type: "userMessage", id: "u2", clientId: "local-steer-1", content: [{ type: "text", text: "steer", text_elements: [] }] },
{ type: "agentMessage", id: "a2", text: "second done", phase: "final_answer", memoryCitation: null },
],
},
},
} satisfies Extract<ServerNotification, { method: "turn/completed" }>);
expect(state.displayItems.map((item) => item.id)).toEqual(["u1", "a1", "u2", "a2"]);
expect(state.displayItems).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "u1", clientId: "local-user-1", text: "start" }),
expect.objectContaining({ id: "a1", text: "first done" }),
expect.objectContaining({ id: "u2", clientId: "local-steer-1", text: "steer" }),
expect.objectContaining({ id: "a2", text: "second done" }),
]),
);
});
it("asks the view to auto-name completed turns", () => {
const state = createChatState();
state.activeThreadId = "thread-active";