mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 17:30:31 +00:00
Reconcile optimistic user messages by client id
This commit is contained in:
parent
a804bca4b7
commit
0480daf4cf
4 changed files with 165 additions and 9 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Reference in a new issue