diff --git a/.project-docs/30-worklog/tasks/20260824-pi-remove-opencode-a41c9e72.md b/.project-docs/30-worklog/tasks/20260824-pi-remove-opencode-a41c9e72.md index 7e833c3..67cb728 100644 --- a/.project-docs/30-worklog/tasks/20260824-pi-remove-opencode-a41c9e72.md +++ b/.project-docs/30-worklog/tasks/20260824-pi-remove-opencode-a41c9e72.md @@ -120,13 +120,14 @@ noise remains. - `pnpm run typecheck`: passed, including after the pinned-pnpm dependency rebuild. - `pnpm exec eslint . --quiet`: passed. -- Full unit suite after planner fixes: 174 files passed; 1495 tests passed, - 2 skipped (1497 total). +- Full unit suite after planner fixes: 174 files passed; 1496 tests passed, + 2 skipped (1498 total). - Pressure suite: 1 file and 1 test passed. -- Focused post-review suite: 3 files and 33 tests passed for migration, +- Focused post-review suite: 3 files and 34 tests passed for migration, session-observation sync, and Conversation store settlement behavior. It covers valid-v2 no-legacy-read, unknown/modified Agent backup, live ordinary-prompt - settlement, no hydrate/follow-up replay, and assistant-natural-language gating. + settlement, no hydrate/follow-up replay, and current-turn assistant natural- + language gating that cannot reuse an older assistant reply. - Electron E2E: 3 tests passed for first Pi conversation latency/submit ordering, Pi product UI, and bundled Skill project configuration. - `pnpm run build:vite`: passed (Renderer, Electron Main, Preload, and utility). diff --git a/src/lib/agent-session-sync.ts b/src/lib/agent-session-sync.ts index 483a5e8..e39193d 100644 --- a/src/lib/agent-session-sync.ts +++ b/src/lib/agent-session-sync.ts @@ -363,8 +363,22 @@ export function queueAgentSessionSync( /** Queue the final product Conversation snapshot after a completed prompt turn. */ export function queueCodingConversationSessionSync(snapshot: ConversationSnapshot): void { - const messages: RawMessage[] = snapshot.nodes.flatMap((node) => { - if (node.kind !== 'message') return []; + const messageNodes = snapshot.nodes.filter((node) => node.kind === 'message'); + let lastUserIndex = -1; + for (let index = messageNodes.length - 1; index >= 0; index -= 1) { + if (messageNodes[index].role === 'user') { + lastUserIndex = index; + break; + } + } + if (lastUserIndex < 0) return; + const currentTurnAssistantIds = new Set(messageNodes + .slice(lastUserIndex + 1) + .filter((node) => node.role === 'assistant') + .map((node) => node.id)); + if (currentTurnAssistantIds.size === 0) return; + + const messages: RawMessage[] = messageNodes.flatMap((node) => { return [{ id: node.id, role: node.role, @@ -384,7 +398,11 @@ export function queueCodingConversationSessionSync(snapshot: ConversationSnapsho messages, updatedAt, ); - if (!data?.messages.some((message) => message.role === 'assistant')) return; + if (!data?.messages.some((message) => ( + message.role === 'assistant' + && typeof message.id === 'string' + && currentTurnAssistantIds.has(message.id) + ))) return; queueAgentSessionData(data); } diff --git a/tests/unit/agent-session-sync.test.ts b/tests/unit/agent-session-sync.test.ts index 6adf545..a7a4533 100644 --- a/tests/unit/agent-session-sync.test.ts +++ b/tests/unit/agent-session-sync.test.ts @@ -220,6 +220,45 @@ describe('local Agent session sync', () => { expect(pushSessionDataMock).not.toHaveBeenCalled(); }); + it('does not reuse an older assistant reply when the current turn has no reply', async () => { + const base = createProductSnapshot('conversation-1'); + queueCodingConversationSessionSync({ + ...base, + nodes: [ + { + kind: 'message', + id: 'old-user', + role: 'user', + status: 'complete', + blocks: [{ kind: 'text', id: 'old-question', text: '旧问题', status: 'complete' }], + }, + { + kind: 'message', + id: 'old-assistant', + role: 'assistant', + status: 'complete', + blocks: [{ kind: 'text', id: 'old-answer', text: '旧回答', status: 'complete' }], + }, + { + kind: 'message', + id: 'current-user', + role: 'user', + status: 'complete', + blocks: [{ kind: 'text', id: 'current-question', text: '新问题', status: 'complete' }], + }, + ], + run: { + status: 'idle', + runId: 'run-2', + settledAt: Date.parse('2026-08-24T08:01:00.000Z'), + terminalReason: 'completed', + }, + }); + await flushPendingAgentSessionSync(); + + expect(pushSessionDataMock).not.toHaveBeenCalled(); + }); + it('never uploads a pending snapshot under a different authenticated account', async () => { queueAgentSessionSync('prj_1', 'ses_1', [ { id: 'user-1', role: 'user', content: '账号 A 的问题' },