Skip to content

Commit 4aecee1

Browse files
committed
fix(chat): read distinct stored instructions on receipt replay
Signed-off-by: huangruiteng <huangrt01@163.com>
1 parent 4d77c55 commit 4aecee1

5 files changed

Lines changed: 48 additions & 8 deletions

File tree

‎apps/presentation/dashboard/src/data/conversation-returns.test.mjs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,4 +67,16 @@ assert.equal(hydratedRoles.length, 2);
6767
assert.equal(hydratedRoles[0].sourceMessageId, "user-message");
6868
assert.equal(hydratedRoles[1].sourceMessageId, "agent-message");
6969
assert.equal(hydratedRoles[1].text, "Live answer");
70+
// The initial optimistic request is the first user message of its Turn.
71+
// Later instructions must retain independent stored identities and remain visible.
72+
const withInstructions = [storedRoles[0],
73+
{ session_id: "current", message_id: "instruction-1", turn_id: "same-turn", role: "user", text: "Chinese first" },
74+
{ session_id: "current", message_id: "instruction-2", turn_id: "same-turn", role: "user", text: "Do not publish" },
75+
storedRoles[1]];
76+
const recoveredInstructions = reconcileConversationHistory(bothRoles, withInstructions, createHistory);
77+
assert.equal(recoveredInstructions.length, 4);
78+
assert.equal(recoveredInstructions.find(row => row.text === "My request").sourceMessageId, "user-message");
79+
assert.equal(recoveredInstructions.find(row => row.text === "Chinese first").sourceMessageId, "instruction-1");
80+
assert.equal(recoveredInstructions.find(row => row.text === "Do not publish").sourceMessageId, "instruction-2");
81+
assert.equal(reconcileConversationHistory(recoveredInstructions, withInstructions, createHistory), recoveredInstructions);
7082
console.log("conversation-returns: passed (session isolation, late return, deduplication, transport uncertainty, stream preservation and watch retirement)");

‎apps/presentation/dashboard/src/data/conversation-returns.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,14 @@ export function reconcileConversationReturns<T extends ConversationMessage>(
3131
createReply: (message: ChatVisibleMessage) => T,
3232
): T[] {
3333
const byId = new Map(messages.map((row) => [row.message_id, row]));
34-
const byTurn = new Map(messages.filter((row) => row.origin !== "manager_followup")
35-
.map((row) => [`${row.turn_id}:${row.role === "user" ? "user" : "assistant"}`, row]));
34+
const byTurn = new Map<string, ChatVisibleMessage>();
35+
for (const row of messages) {
36+
if (row.origin === "manager_followup") continue;
37+
const key = `${row.turn_id}:${row.role === "user" ? "user" : "assistant"}`;
38+
// The Turn starts with its user request. Later user instructions have their
39+
// own message IDs; they must not hydrate that original optimistic request.
40+
if (row.role !== "user" || !byTurn.has(key)) byTurn.set(key, row);
41+
}
3642
const seen = new Set(previous.filter((row) => row.sourceSessionId === sessionId).map((row) => row.sourceMessageId));
3743
let changed = false;
3844
const updated = previous.map((row) => {

‎apps/presentation/dashboard/src/views/dashboard-page.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2956,7 +2956,7 @@ function PersonalGoalHome({
29562956
if (receipt.created === false) {
29572957
// A replay reads an existing delivery; its message belongs to the
29582958
// stored transcript, not a second optimistic user bubble.
2959-
if (targetContextId === contextId) conversationHistory.retry();
2959+
if (targetContextId === contextId) await conversationHistory.refresh();
29602960
return;
29612961
}
29622962
const id = managerMessageId.current++;

‎docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,10 @@ receipt → observed work or actionable failure → readable answer in the same
137137
composer persists the original Session/Turn/text/ingress before dispatch and
138138
reuses it only for the matching Session and text; restoring the cache never
139139
sends automatically. Only a confirmed non-delivery permits a new ingress.
140-
Replaying a delivered receipt uses the stored transcript rather than
141-
inserting another local copy of the instruction.
140+
Replaying a delivered receipt reads the current stored transcript rather than
141+
reusing an earlier snapshot or inserting another local copy of the instruction.
142+
History keeps the initial user request and later instructions as distinct messages
143+
even while the initial request is still being reconciled with its stored identity.
142144
Unavailable browser storage keeps current-page retry behavior but cannot
143145
promise reload recovery. This tab-local cache is not delivery authority.
144146
Acceptance means the executor received the instructions, not that it adopted

‎examples/personal-workspace-browser/composer-session-admission.mjs‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ export const composerSessionAdmissionScenario = {
5656
const body = route.request().postDataJSON();
5757
posts.push(body.message);
5858
const queuedTurnId = `${turnId}-queued-${posts.length}`;
59+
page.__loopxRuntime.messages.get(sessionId).push({ message_id: `${queuedTurnId}-user`, turn_id: queuedTurnId,
60+
role: "user", text: body.message, created_at: "2026-08-13T01:00:01Z" });
5961
// A queued follow-up waits behind the running Turn; its stream is held too.
6062
routes.push(`**/api/chat/sessions/${sessionId}/turns/${queuedTurnId}/events`);
6163
await page.route(routes.at(-1), (held) => { heldEvents.push(held); });
@@ -84,10 +86,14 @@ export const composerSessionAdmissionScenario = {
8486
const body = route.request().postDataJSON();
8587
const target = new URL(route.request().url()).pathname.split("/")[6];
8688
adjustments.push({ ...body, turnId: target });
87-
if ([1, 6].includes(adjustments.length)) return route.fulfill({ status: 409, json: { ok: false, error: "接收状态未确认" } });
89+
if (adjustments.length === 9) {
90+
page.__loopxRuntime.messages.get(sessionId).push({ message_id: "stored-current-page", turn_id: target,
91+
role: "user", text: body.message, created_at: "2026-08-13T01:00:02Z" });
92+
}
93+
if ([1, 6, 9].includes(adjustments.length)) return route.fulfill({ status: 409, json: { ok: false, error: "接收状态未确认" } });
8894
if (adjustments.length === 4) return route.fulfill({ status: 409, json: { ok: false, error: "本次未送达", delivery_state: "not_delivered" } });
8995
const receipt = { ok: true, session_id: sessionId, turn_id: adjustments.length === 2 ? "wrong-turn" : target,
90-
client_ingress_id: body.client_ingress_id, status: "delivered", created: adjustments.length !== 7 };
96+
client_ingress_id: body.client_ingress_id, status: "delivered", created: ![7, 10].includes(adjustments.length) };
9197
if (adjustments.length === 3) { delayedReceipt = () => route.fulfill({ json: receipt }); return; }
9298
await route.fulfill({ json: receipt });
9399
});
@@ -165,6 +171,20 @@ export const composerSessionAdmissionScenario = {
165171
|| adjustments[7].turnId === managed.turnId || await composerInput.inputValue()) {
166172
throw new Error("Instructions during the original send started new work or failed to clear the submitted draft");
167173
}
174+
// Without reloading, a delivered replay must fetch the new stored message,
175+
// not reuse the history snapshot taken before the instruction was sent.
176+
const currentPageInstruction = "问卷里补上使用频率。";
177+
await composerInput.fill(currentPageInstruction);
178+
await sendButton.click();
179+
await page.getByRole("status").filter({ hasText: "接收状态未确认" }).waitFor();
180+
await sendButton.click();
181+
await page.getByRole("status").filter({ hasText: "执行器已接收本轮追加指令" }).waitFor();
182+
const currentPageStored = page.locator(".personal-message").getByText(currentPageInstruction, { exact: true });
183+
await currentPageStored.waitFor({ timeout: 5000 });
184+
if (await currentPageStored.count() !== 1 || adjustments.length !== 10
185+
|| adjustments[8].client_ingress_id !== adjustments[9].client_ingress_id || managed.posts.length !== 1) {
186+
throw new Error("Delivered replay did not read back one stored instruction on the current page");
187+
}
168188
await managed.release();
169189
notes.push("managed Codex: ordinary composer steers its exact Turn while the original send waits, retaining drafts and retry identity, including after completion and reload; restored requests never dispatch automatically");
170190

@@ -174,7 +194,7 @@ export const composerSessionAdmissionScenario = {
174194
if (!await sendButton.isDisabled()) throw new Error("Unsupported executor was offered native steering");
175195
await sendButton.click({ force: true });
176196
await page.waitForTimeout(100);
177-
if (unsupported.posts.length || adjustments.length !== 8) throw new Error("Unsupported executor received an effect");
197+
if (unsupported.posts.length || adjustments.length !== 10) throw new Error("Unsupported executor received an effect");
178198
await composerInput.fill("");
179199
await unsupported.release();
180200
notes.push("unsupported managed adapter: draft retained and no Turn or steering effect");

0 commit comments

Comments
 (0)