fix: scope observation to current turn

Require assistant natural language after the latest user message so a completed prompt cannot reuse historical replies when queuing the observation snapshot.
This commit is contained in:
2026-08-24 12:55:01 +08:00
parent 7a15b1b49c
commit 16f5c57f17
3 changed files with 65 additions and 7 deletions

View File

@@ -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).

View File

@@ -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);
}

View File

@@ -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 的问题' },