fix: close PI-140 cutover gaps
Back up unknown legacy Agents, reconnect settled Conversation observation, remove remaining active repository residue, and restore the bundled Python verifier import.
This commit is contained in:
@@ -3,10 +3,12 @@ import type { RawMessage } from '@/types/chat';
|
||||
import {
|
||||
buildAgentSessionData,
|
||||
flushPendingAgentSessionSync,
|
||||
queueCodingConversationSessionSync,
|
||||
queueAgentSessionSync,
|
||||
resetAgentSessionSyncForTests,
|
||||
sanitizeAgentSessionText,
|
||||
} from '@/lib/agent-session-sync';
|
||||
import { createProductSnapshot } from '../fixtures/coding-conversation-product-fixtures';
|
||||
|
||||
const getAuthStateMock = vi.hoisted(() => vi.fn());
|
||||
const getProfileStateMock = vi.hoisted(() => vi.fn());
|
||||
@@ -145,6 +147,79 @@ describe('local Agent session sync', () => {
|
||||
]));
|
||||
});
|
||||
|
||||
it('uploads natural-language messages from the final product Conversation snapshot', async () => {
|
||||
queueCodingConversationSessionSync({
|
||||
...createProductSnapshot('conversation-1'),
|
||||
conversation: {
|
||||
...createProductSnapshot('conversation-1').conversation,
|
||||
projectId: 'project-1',
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
kind: 'message',
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
status: 'complete',
|
||||
blocks: [{ kind: 'text', id: 'user-text', text: '解释一下。', status: 'complete' }],
|
||||
},
|
||||
{
|
||||
kind: 'message',
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
status: 'complete',
|
||||
blocks: [
|
||||
{ kind: 'thinking', id: 'thought', text: '内部推理', status: 'complete' },
|
||||
{ kind: 'text', id: 'answer', text: '这是自然语言回答。', status: 'complete' },
|
||||
],
|
||||
},
|
||||
],
|
||||
run: {
|
||||
status: 'idle',
|
||||
runId: 'run-1',
|
||||
settledAt: Date.parse('2026-08-24T08:00:00.000Z'),
|
||||
terminalReason: 'completed',
|
||||
},
|
||||
});
|
||||
await flushPendingAgentSessionSync();
|
||||
|
||||
expect(pushSessionDataMock).toHaveBeenCalledWith(
|
||||
'user-a',
|
||||
'access-token',
|
||||
{
|
||||
project_id: 'project-1',
|
||||
session_id: 'conversation-1',
|
||||
updated_at: '2026-08-24T08:00:00.000Z',
|
||||
messages: [
|
||||
{ id: 'user-1', role: 'user', text: '解释一下。' },
|
||||
{ id: 'assistant-1', role: 'assistant', text: '这是自然语言回答。' },
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('does not queue a product Conversation without assistant natural language', async () => {
|
||||
const base = createProductSnapshot('conversation-1');
|
||||
queueCodingConversationSessionSync({
|
||||
...base,
|
||||
nodes: [{
|
||||
kind: 'message',
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
status: 'complete',
|
||||
blocks: [{ kind: 'thinking', id: 'thought', text: '内部推理', status: 'complete' }],
|
||||
}],
|
||||
run: {
|
||||
status: 'idle',
|
||||
runId: 'run-1',
|
||||
settledAt: Date.parse('2026-08-24T08:00: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 的问题' },
|
||||
|
||||
@@ -138,6 +138,75 @@ function ids() {
|
||||
}
|
||||
|
||||
describe('coding Conversation store', () => {
|
||||
it('queues observation sync only for a live completed ordinary prompt', () => {
|
||||
const queueSettledSessionSync = vi.fn();
|
||||
const store = createCodingConversationStore({
|
||||
getSnapshot: vi.fn(),
|
||||
openEvents: vi.fn(),
|
||||
submitPrompt: vi.fn(),
|
||||
queueSettledSessionSync,
|
||||
createId: ids(),
|
||||
});
|
||||
const running = {
|
||||
...snapshot('conversation-a', 1, 3),
|
||||
run: { status: 'running', runId: 'run-a', mode: 'prompt' } as const,
|
||||
};
|
||||
store.getState().applySnapshotEvent(snapshotEvent(running));
|
||||
|
||||
expect(queueSettledSessionSync).not.toHaveBeenCalled();
|
||||
store.getState().applyPatchBatchEvent(patchEvent('conversation-a', 4, {
|
||||
op: 'run.state',
|
||||
run: {
|
||||
status: 'idle',
|
||||
runId: 'run-a',
|
||||
settledAt: 1_004,
|
||||
terminalReason: 'completed',
|
||||
},
|
||||
}));
|
||||
|
||||
expect(queueSettledSessionSync).toHaveBeenCalledTimes(1);
|
||||
expect(queueSettledSessionSync).toHaveBeenCalledWith(expect.objectContaining({
|
||||
conversation: expect.objectContaining({ id: 'conversation-a', projectId: 'project-a' }),
|
||||
run: expect.objectContaining({ status: 'idle', terminalReason: 'completed' }),
|
||||
cursor: expect.objectContaining({ seq: 4 }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('does not queue observation sync for hydration or a follow-up settlement', () => {
|
||||
const queueSettledSessionSync = vi.fn();
|
||||
const store = createCodingConversationStore({
|
||||
getSnapshot: vi.fn(),
|
||||
openEvents: vi.fn(),
|
||||
submitPrompt: vi.fn(),
|
||||
queueSettledSessionSync,
|
||||
createId: ids(),
|
||||
});
|
||||
store.getState().applySnapshotEvent(snapshotEvent({
|
||||
...snapshot('conversation-a', 1, 3),
|
||||
run: {
|
||||
status: 'idle',
|
||||
runId: 'hydrated-run',
|
||||
settledAt: 1_003,
|
||||
terminalReason: 'completed',
|
||||
},
|
||||
}));
|
||||
store.getState().applySnapshotEvent(snapshotEvent({
|
||||
...snapshot('conversation-b', 1, 3),
|
||||
run: { status: 'running', runId: 'run-b', mode: 'follow-up' },
|
||||
}));
|
||||
store.getState().applyPatchBatchEvent(patchEvent('conversation-b', 4, {
|
||||
op: 'run.state',
|
||||
run: {
|
||||
status: 'idle',
|
||||
runId: 'run-b',
|
||||
settledAt: 1_004,
|
||||
terminalReason: 'completed',
|
||||
},
|
||||
}));
|
||||
|
||||
expect(queueSettledSessionSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('isolates interleaved snapshot and patch state for two Conversations', async () => {
|
||||
const source = new FakeEventSource();
|
||||
const getSnapshot = vi.fn(async (conversationId: string) => snapshot(conversationId));
|
||||
|
||||
@@ -202,7 +202,7 @@ describe('coding project v1 to v2 migration', () => {
|
||||
]);
|
||||
expect(resolveLegacyModel).toHaveBeenCalledTimes(2);
|
||||
expect(result.removedGeneratedAgents.sort()).toEqual(['no-account.md', 'unique.md']);
|
||||
expect(result.backedUpUncertainAgents).toEqual(['unresolved.md']);
|
||||
expect(result.backedUpUncertainAgents).toEqual(['custom.md', 'unresolved.md']);
|
||||
expect(JSON.parse(await readFile(
|
||||
path.join(staged.projectPath, '.niancode', 'conversations.json'),
|
||||
'utf8',
|
||||
@@ -215,8 +215,12 @@ describe('coding project v1 to v2 migration', () => {
|
||||
path.join(result.backupDirectory, '.opencode', 'agent', 'unresolved.md'),
|
||||
'utf8',
|
||||
)).toBe('locally modified Agent\n');
|
||||
expect(await readFile(
|
||||
path.join(result.backupDirectory, '.opencode', 'agent', 'custom.md'),
|
||||
'utf8',
|
||||
)).toBe('unknown Agent\n');
|
||||
await expect(readFile(unresolvedFile, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
expect(await readFile(customFile, 'utf8')).toBe('unknown Agent\n');
|
||||
await expect(readFile(customFile, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
expect(await readFile(path.join(staged.projectPath, '.opencode', 'skills', 'keep.md'), 'utf8'))
|
||||
.toBe('keep me');
|
||||
|
||||
@@ -249,6 +253,8 @@ describe('coding project v1 to v2 migration', () => {
|
||||
|
||||
it('restores retryable v1 state after a corrupt metadata write and succeeds on retry', async () => {
|
||||
const staged = await stageLegacyProject();
|
||||
const customFile = path.join(staged.projectPath, '.opencode', 'agent', 'custom.md');
|
||||
await writeFile(customFile, 'unknown Agent\n', 'utf8');
|
||||
let writeCount = 0;
|
||||
const corruptingWriter = vi.fn(async (filePath: string, value: unknown) => {
|
||||
writeCount += 1;
|
||||
@@ -273,6 +279,7 @@ describe('coding project v1 to v2 migration', () => {
|
||||
expect(await readFile(path.join(staged.projectPath, '.opencode', entry.relativePath), 'utf8'))
|
||||
.toBe(entry.content);
|
||||
}
|
||||
expect(await readFile(customFile, 'utf8')).toBe('unknown Agent\n');
|
||||
|
||||
const retry = await migrateCodingProjectToV2(staged.projectPath, {
|
||||
resolveLegacyModel: async ({ legacyModel }) => legacyModel === 'legacy/unique' ? MODEL : null,
|
||||
@@ -283,5 +290,11 @@ describe('coding project v1 to v2 migration', () => {
|
||||
path.join(staged.projectPath, '.niancode', 'conversations.json'),
|
||||
'utf8',
|
||||
))).toEqual({ schemaVersion: 2, conversations: [] });
|
||||
expect(retry.backedUpUncertainAgents).toEqual(['custom.md']);
|
||||
await expect(readFile(customFile, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
expect(await readFile(
|
||||
path.join(retry.backupDirectory, '.opencode', 'agent', 'custom.md'),
|
||||
'utf8',
|
||||
)).toBe('unknown Agent\n');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user