From 0354a10e07fa47a470a625705d21fd3ea08f8a84 Mon Sep 17 00:00:00 2001 From: QuinnWan <144975606+somewan820@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:18:22 +0800 Subject: [PATCH 1/8] =?UTF-8?q?fix(desktop):=20=E6=97=A2=E6=9C=89=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E5=8F=91=E9=80=81=E4=B8=8D=E5=86=8D=E6=8A=A5=E5=91=8A?= =?UTF-8?q?=E5=B7=B2=E8=A7=A3=E6=9E=90=E4=BC=9A=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit send() 的既有会话分支无条件调用 options.onSessionResolved(sessionId), 但该回调的契约是「本次发送新建且首条消息成功投射的会话」(新会话 分支),既有会话发送不应触发。当前 main 上该回调尚无消费者,属于 死代码;但 #4598 接入 start-task 后它会把 Work Board 条目误链接到 无关的既有会话。此处先修掉根本问题,避免后续消费方被错误绑定。 新增回归测试:既有会话 projected 发送不触发 onSessionResolved。 --- .../app-shell-first-send-cleanup.test.ts | 35 +++++++++++++++++++ .../src/renderer/app-shell-chat-actions.ts | 6 +++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index 495643628f..3c3daa74b1 100644 --- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts @@ -423,6 +423,41 @@ describe('composer first-send cleanup', () => { assert.deepEqual(removed, []); }); + it('does not report a resolved Session from an existing-Session send', async () => { + // `onSessionResolved` is the contract for a Session this send created and + // whose first message projected (the new-Session branch). An existing- + // Session send must never fire it, or a consumer binding follow-up state + // to a newly resolved Session (e.g. a Work Board start claim) would bind + // it to an unrelated pre-existing conversation. + let resolved = 0; + const restoreWindow = installWindow({ + sessions: { + submitMessage: async () => ({ + ok: true, + attachments: [], + skillInvocation: { loaded: [], failed: [] }, + }), + }, + }); + + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef: { current: 'existing-session' }, + }); + const result = await actions.send('hello', undefined, { + onSessionResolved: () => { + resolved += 1; + }, + }); + assert.equal(result, true); + } finally { + restoreWindow(); + } + + assert.equal(resolved, 0); + }); + it('returns a sparse existing session to latest before sending', async () => { const latest = deferred(); const order: string[] = []; diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 275740b9c4..ab95c66d2d 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -612,7 +612,11 @@ export function createAppShellChatActions(deps: { }); if (submitted.kind === 'refused') return false; if (submitted.kind === 'unreconciled') return true; - options.onSessionResolved?.(sessionId); + // `onSessionResolved` is the contract for a Session this send CREATED + // and whose first message projected (the new-Session branch above). An + // existing-Session send must never report it, or a consumer that binds + // follow-up state to a newly resolved Session would bind it to an + // unrelated pre-existing conversation. return true; } catch (error) { // Capture ownership before cleanup clears the optimistic Session. A From 50cbf275150cd72c2a1ccb5a1dbe3427bb0a5fda Mon Sep 17 00:00:00 2001 From: QuinnWan <144975606+somewan820@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:25:16 +0800 Subject: [PATCH 2/8] =?UTF-8?q?chore(desktop):=20=E5=90=8C=E6=AD=A5=20rend?= =?UTF-8?q?erer=20architecture=20ledger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app-shell-chat-actions.ts 的 nonTriviaTokens 从 4278 降至 4275 (删除既有会话分支对 onSessionResolved 的无条件调用),同步 renderer-architecture.json 的 ratchet 基线。 --- apps/desktop/renderer-architecture.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 387101dc9c..c5474bdd67 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -352,7 +352,7 @@ "@maka/ui": 1 }, "importSpecifiers": 39, - "nonTriviaTokens": 4278 + "nonTriviaTokens": 4275 }, "src/renderer/app-shell-chrome-actions.tsx": { "importDeclarations": 5, From 47c2dbd43a67025b47617a5dd8d04cd13410abab Mon Sep 17 00:00:00 2001 From: QuinnWan <144975606+somewan820@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:37:02 +0800 Subject: [PATCH 3/8] =?UTF-8?q?ci:=20=E9=87=8D=E8=A7=A6=E5=8F=91=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=EF=BC=88transcript-scroll=20e2e=20=E7=96=91=E4=BC=BC?= =?UTF-8?q?=20flaky=EF=BC=8C=E4=B8=8E=E6=9C=AC=E6=AC=A1=E6=94=B9=E5=8A=A8?= =?UTF-8?q?=E6=97=A0=E4=BA=A4=E9=9B=86=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From efcfea0d7726df3ff740ea4b3a92b2097f3fea8e Mon Sep 17 00:00:00 2001 From: QuinnWan <144975606+somewan820@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:48:43 +0800 Subject: [PATCH 4/8] =?UTF-8?q?ci:=20=E5=86=8D=E8=A7=A6=E5=8F=91=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=EF=BC=88=E9=87=8D=E8=AF=95=20e2e=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From c7f8fae785a797731435e9dbc7c5ef03837e3df1 Mon Sep 17 00:00:00 2001 From: QuinnWan <144975606+somewan820@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:38:51 +0800 Subject: [PATCH 5/8] =?UTF-8?q?fix(desktop):=20=E4=BB=85=E5=9C=A8=E9=A6=96?= =?UTF-8?q?=E6=9D=A1=E6=B6=88=E6=81=AF=E6=8A=95=E5=B0=84=E6=97=B6=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=E5=B7=B2=E8=A7=A3=E6=9E=90=E4=BC=9A=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 PR 审查(M4n5ter review 5099565705)收窄 onSessionResolved 契约:新会话 分支的回调不再对 outcome_unknown(unreconciled)触发——发送结果未知时首条 消息未被确认投射,send() 虽返回 true,回调也不得上报,否则消费方会把 Work Board 条目链接到首条消息从未确认的会话。 新增回归测试:新会话 + outcome_unknown 时 send() 返回 true、会话保留、 onSessionResolved 不触发。desktop main 全量 2005/2005 通过。 Generated-by: pi --- .../app-shell-first-send-cleanup.test.ts | 40 +++++++++++++++++++ .../src/renderer/app-shell-chat-actions.ts | 13 +++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index 3c3daa74b1..552a34bc37 100644 --- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts @@ -458,6 +458,46 @@ describe('composer first-send cleanup', () => { assert.equal(resolved, 0); }); + it('does not report a resolved Session when the first send is outcome_unknown', async () => { + // `onSessionResolved` is the contract for a Session this send created AND + // whose first message projected. `outcome_unknown` maps to `unreconciled`: + // `send()` intentionally returns `true` (the Message may well have been + // admitted), but the callback must not fire — a consumer binding follow-up + // state to a newly resolved Session (e.g. a Work Board start claim) would + // otherwise bind to a Session whose first message was never confirmed. + let resolved = 0; + const removed: string[] = []; + const restoreWindow = installWindow({ + newTasks: { create: async () => ({ id: 'session-1' }) }, + sessions: { + submitMessage: async () => ({ + ok: false, + reason: 'outcome_unknown' as const, + }), + remove: async (sessionId: string) => { + removed.push(sessionId); + }, + }, + }); + + try { + const actions = createAppShellChatActions(createActionsDeps()); + const result = await actions.send('hello', undefined, { + onSessionResolved: () => { + resolved += 1; + }, + }); + // The row stays for canonical transcript to settle, so the session is + // kept and the send reports success — only the callback is silenced. + assert.equal(result, true); + assert.deepEqual(removed, []); + } finally { + restoreWindow(); + } + + assert.equal(resolved, 0); + }); + it('returns a sparse existing session to latest before sending', async () => { const latest = deferred(); const order: string[] = []; diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index ab95c66d2d..1e5eb924fc 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -542,7 +542,14 @@ export function createAppShellChatActions(deps: { return false; } unsentSessionId = undefined; - options.onSessionResolved?.(session.id); + // The callback only fires when this send's first message actually + // projected. `unreconciled` (outcome_unknown) may well have been + // admitted, but nothing proves it — reporting it would bind a consumer + // to a Session whose first message was never confirmed as projected, + // and the caller cannot correct that after `send()` returns `true`. + if (submitted.kind === 'projected') { + options.onSessionResolved?.(session.id); + } await refreshSessions(); return true; } @@ -616,7 +623,9 @@ export function createAppShellChatActions(deps: { // and whose first message projected (the new-Session branch above). An // existing-Session send must never report it, or a consumer that binds // follow-up state to a newly resolved Session would bind it to an - // unrelated pre-existing conversation. + // unrelated pre-existing conversation. An `unreconciled` first send is + // also excluded: `send()` reports it as `true`, but the callback never + // fires because the message was never confirmed as projected. return true; } catch (error) { // Capture ownership before cleanup clears the optimistic Session. A From bc3c38aa54fd4af238edc2da30648cefaf67e9d9 Mon Sep 17 00:00:00 2001 From: QuinnWan <144975606+somewan820@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:43:15 +0800 Subject: [PATCH 6/8] =?UTF-8?q?chore(desktop):=20=E5=90=8C=E6=AD=A5=20rend?= =?UTF-8?q?erer=20architecture=20ledger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app-shell-chat-actions.ts 新增 onSessionResolved 契约注释与 projected 分支后 nonTriviaTokens 4275 -> 4292,同步台账。 Generated-by: pi --- apps/desktop/renderer-architecture.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index c5474bdd67..6591c2bf12 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -352,7 +352,7 @@ "@maka/ui": 1 }, "importSpecifiers": 39, - "nonTriviaTokens": 4275 + "nonTriviaTokens": 4292 }, "src/renderer/app-shell-chrome-actions.tsx": { "importDeclarations": 5, From 325aacd0fcf88ce5b61893f6a96df5950d946f61 Mon Sep 17 00:00:00 2001 From: QuinnWan <144975606+somewan820@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:53:43 +0800 Subject: [PATCH 7/8] =?UTF-8?q?fix(desktop):=20=E4=BB=85=E5=9C=A8=E9=A6=96?= =?UTF-8?q?=E6=9D=A1=E6=B6=88=E6=81=AF=E6=8A=95=E5=B0=84=E6=97=B6=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=E5=B7=B2=E8=A7=A3=E6=9E=90=E4=BC=9A=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 review 5099565705 收窄 onSessionResolved 契约:new-session 分支的回调 只在 submitted.kind === 'projected' 时触发,outcome_unknown(unreconciled) 不再上报——send() 对该结果有意返回 true,但首条消息未被确认投射时回调 也不得上报,否则消费方会把 Work Board 条目链接到首条消息从未确认的会话。 既有会话分支行为不变(永不触发)。 renderer architecture 单调 debt ratchet 要求 legacy AppShell 文件 nonTriviaTokens 只减不增,故采用单行 if 的 token 中性实现(4278,与 base 持平),契约说明由回归测试注释承载;同步 renderer architecture ledger。 Generated-by: pi --- apps/desktop/renderer-architecture.json | 2 +- .../src/renderer/app-shell-chat-actions.ts | 16 +--------------- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 6591c2bf12..387101dc9c 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -352,7 +352,7 @@ "@maka/ui": 1 }, "importSpecifiers": 39, - "nonTriviaTokens": 4292 + "nonTriviaTokens": 4278 }, "src/renderer/app-shell-chrome-actions.tsx": { "importDeclarations": 5, diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 1e5eb924fc..da1394ad2e 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -542,14 +542,7 @@ export function createAppShellChatActions(deps: { return false; } unsentSessionId = undefined; - // The callback only fires when this send's first message actually - // projected. `unreconciled` (outcome_unknown) may well have been - // admitted, but nothing proves it — reporting it would bind a consumer - // to a Session whose first message was never confirmed as projected, - // and the caller cannot correct that after `send()` returns `true`. - if (submitted.kind === 'projected') { - options.onSessionResolved?.(session.id); - } + if (submitted.kind === 'projected') options.onSessionResolved?.(session.id); await refreshSessions(); return true; } @@ -619,13 +612,6 @@ export function createAppShellChatActions(deps: { }); if (submitted.kind === 'refused') return false; if (submitted.kind === 'unreconciled') return true; - // `onSessionResolved` is the contract for a Session this send CREATED - // and whose first message projected (the new-Session branch above). An - // existing-Session send must never report it, or a consumer that binds - // follow-up state to a newly resolved Session would bind it to an - // unrelated pre-existing conversation. An `unreconciled` first send is - // also excluded: `send()` reports it as `true`, but the callback never - // fires because the message was never confirmed as projected. return true; } catch (error) { // Capture ownership before cleanup clears the optimistic Session. A From d1cfe834963f3b254047992f42f1753dbd5609f1 Mon Sep 17 00:00:00 2001 From: QuinnWan <144975606+somewan820@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:32:58 +0800 Subject: [PATCH 8/8] =?UTF-8?q?refactor(desktop):=20=E6=94=B6=E6=95=9B=20s?= =?UTF-8?q?end()=20=E9=87=8D=E5=A4=8D=E5=B9=B6=E7=A7=BB=E9=99=A4=E6=AD=BB?= =?UTF-8?q?=E5=88=86=E6=94=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 提取 submitIntoSession(sessionId, messageId) helper,合并 new-session 与 existing-session 两条发送路径逐字重复的 ~30 行(sendCommand 构建与 submitAndProject 调用),消除两处需同步的负担 - 删除 existing-session 分支的 `if (submitted.kind === 'unreconciled') return true` 死分支:base 上该行用于跳过 onSessionResolved 调用,本 PR 删除该调用后它与末尾 return true 完全等价 - 借 helper 提取释放的 token 预算(nonTriviaTokens 4278→4089,仍低于 base),恢复被 renderer architecture ratchet 挤掉的 onSessionResolved 契约注释;同步 architecture ledger(4089 < base 4278,满足单调 ratchet) Generated-by: pi --- apps/desktop/renderer-architecture.json | 2 +- .../src/renderer/app-shell-chat-actions.ts | 109 +++++++----------- 2 files changed, 41 insertions(+), 70 deletions(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 387101dc9c..6343003fb6 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -352,7 +352,7 @@ "@maka/ui": 1 }, "importSpecifiers": 39, - "nonTriviaTokens": 4278 + "nonTriviaTokens": 4089 }, "src/renderer/app-shell-chrome-actions.tsx": { "importDeclarations": 5, diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index da1394ad2e..7a877ebf25 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -454,6 +454,41 @@ export function createAppShellChatActions(deps: { }; try { const messageId = crypto.randomUUID(); + async function submitIntoSession(sessionId: string, messageId: string) { + if (exactTurn) armTurnActive(sessionId, messageId); + const attachmentItems = + pending && pending.length > 0 + ? toComposerIngestItems(pending) + : undefined; + const retainedAttachments = + pending && pending.length > 0 + ? retainedAttachmentRefs(pending) + : undefined; + const sendCommand = { + text, + ...(options.displayText ? { displayText: options.displayText } : {}), + ...copiedArray('attachmentItems', attachmentItems), + ...(retainedAttachments && retainedAttachments.length > 0 + ? { retainedAttachments } + : {}), + ...copiedArray('directoryReferences', directoryReferences), + ...copiedArray('quotes', quotes), + ...copiedArray('workspaceFileReferences', options.workspaceFileReferences), + }; + return submitAndProject({ + sessionId, + messageId, + placement: 'current_turn', + command: { + ...sendCommand, + ...(options.turnOrchestration ? { turnOrchestration: options.turnOrchestration } : {}), + }, + ...(options.displayText ? { displayText: options.displayText } : {}), + ...copiedArray('quotes', quotes), + exactTurn, + isSurfaceVisible: () => activeIdRef.current === sessionId, + }); + } if (!initialSessionId) { if (!initialNewTaskTarget) return false; if (pending && pending.length > 0) preflightAttachmentItems(pending, uiLocale); @@ -502,46 +537,14 @@ export function createAppShellChatActions(deps: { await discardUnsentSession(); return false; } - if (exactTurn) armTurnActive(session.id, messageId); - const attachmentItems = - pending && pending.length > 0 - ? toComposerIngestItems(pending) - : undefined; - const retainedAttachments = - pending && pending.length > 0 - ? retainedAttachmentRefs(pending) - : undefined; - const sendCommand = { - text, - ...(options.displayText ? { displayText: options.displayText } : {}), - ...copiedArray('attachmentItems', attachmentItems), - ...(retainedAttachments && retainedAttachments.length > 0 - ? { retainedAttachments } - : {}), - ...copiedArray('directoryReferences', directoryReferences), - ...copiedArray('quotes', quotes), - ...copiedArray('workspaceFileReferences', options.workspaceFileReferences), - }; - const submitted = await submitAndProject({ - sessionId: session.id, - messageId, - placement: 'current_turn', - command: { - ...sendCommand, - ...(options.turnOrchestration - ? { turnOrchestration: options.turnOrchestration } - : {}), - }, - ...(options.displayText ? { displayText: options.displayText } : {}), - ...copiedArray('quotes', quotes), - exactTurn, - isSurfaceVisible: () => activeIdRef.current === session.id, - }); + const submitted = await submitIntoSession(session.id, messageId); if (submitted.kind === 'refused') { await discardUnsentSession(); return false; } unsentSessionId = undefined; + // The callback fires only when this send's first message projected; + // an unreconciled first message stays unreported. if (submitted.kind === 'projected') options.onSessionResolved?.(session.id); await refreshSessions(); return true; @@ -577,41 +580,9 @@ export function createAppShellChatActions(deps: { inlineReferences: [], }, ); - if (exactTurn) armTurnActive(sessionId, messageId); - const attachmentItems = - pending && pending.length > 0 - ? toComposerIngestItems(pending) - : undefined; - const retainedAttachments = - pending && pending.length > 0 - ? retainedAttachmentRefs(pending) - : undefined; - const sendCommand = { - text, - ...(options.displayText ? { displayText: options.displayText } : {}), - ...copiedArray('attachmentItems', attachmentItems), - ...(retainedAttachments && retainedAttachments.length > 0 - ? { retainedAttachments } - : {}), - ...copiedArray('directoryReferences', directoryReferences), - ...copiedArray('quotes', quotes), - ...copiedArray('workspaceFileReferences', options.workspaceFileReferences), - }; - const submitted = await submitAndProject({ - sessionId, - messageId, - placement: 'current_turn', - command: { - ...sendCommand, - ...(options.turnOrchestration ? { turnOrchestration: options.turnOrchestration } : {}), - }, - ...(options.displayText ? { displayText: options.displayText } : {}), - ...copiedArray('quotes', quotes), - exactTurn, - isSurfaceVisible: () => activeIdRef.current === sessionId, - }); + const submitted = await submitIntoSession(sessionId, messageId); if (submitted.kind === 'refused') return false; - if (submitted.kind === 'unreconciled') return true; + // An existing-Session send never reports a resolved Session. return true; } catch (error) { // Capture ownership before cleanup clears the optimistic Session. A