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:
2026-08-24 12:47:16 +08:00
parent 5a275b93a7
commit 7a15b1b49c
12 changed files with 286 additions and 247 deletions

1
.gitignore vendored
View File

@@ -2,7 +2,6 @@
node_modules/ node_modules/
.pnpm-store/ .pnpm-store/
.pnp.* .pnp.*
.opencode/node_modules/
# Build and packaged output # Build and packaged output
build/ build/

View File

@@ -8,7 +8,7 @@
- Worktree: D:\Datas\OthersProjects\makelore-pi-remove-opencode-a41c9e72 - Worktree: D:\Datas\OthersProjects\makelore-pi-remove-opencode-a41c9e72
- Base commit: 61fede2b4dfed3e29a6c4e37aee73896713979e1 - Base commit: 61fede2b4dfed3e29a6c4e37aee73896713979e1
- Owner: codex-root - Owner: codex-root
- Status: Implementation Complete; Planner Review Pending - Status: Needs Fix Addressed; Planner Re-review Pending
## Scope ## Scope
@@ -77,9 +77,17 @@
bundle/smoke scripts, dependencies, and obsolete tests were removed. bundle/smoke scripts, dependencies, and obsolete tests were removed.
- Retained only the migration-owned schema-v1 normalizer and expected generated - Retained only the migration-owned schema-v1 normalizer and expected generated
Agent comparison in `electron/coding-projects/legacy-v1.ts`. Migration keeps Agent comparison in `electron/coding-projects/legacy-v1.ts`. Migration keeps
exact-delete/backup/rollback/notice behavior, valid schema-v2 projects bypass exact-delete/backup/rollback/notice behavior: it enumerates the legacy Agent
legacy reads, unrelated `.opencode` content remains inert, and no `.pi/agents` directory, removes exact generated files, and moves modified plus unknown Agent
directory is introduced. files into the timestamped backup. Valid schema-v2 projects bypass legacy reads,
unrelated `.opencode` content remains inert, and no `.pi/agents` directory is
introduced.
- Preserved the existing session-observation product behavior at the
vendor-neutral boundary. A live ordinary prompt that reaches the authoritative
completed settlement queues the final Coding Conversation Snapshot using the
project id and product Conversation id; hydrate/reconnect snapshots,
steer/follow-up, failed/aborted turns, and replies without assistant natural
language do not enqueue.
- Preserved compatibility identifiers `app.niancode.desktop`, `niancode`, and - Preserved compatibility identifiers `app.niancode.desktop`, `niancode`, and
the persisted electron-store name `opencode-projects`. The remaining source the persisted electron-store name `opencode-projects`. The remaining source
references to OpenCode are categorized compatibility/history boundaries: references to OpenCode are categorized compatibility/history boundaries:
@@ -99,6 +107,10 @@
for its Provider protocol adapters. They are part of Pi's Provider package, for its Provider protocol adapters. They are part of Pi's Provider package,
not the removed OpenCode runtime/package/plugin/fallback, and remain covered by not the removed OpenCode runtime/package/plugin/fallback, and remain covered by
the explicitly accepted real-Provider qualification risk. the explicitly accepted real-Provider qualification risk.
- Removed the unreferenced legacy session run machine and old `.gitignore`
exception, updated current repository guidance and the session-observation
integration guide to Coding/Pi, and restored the `node:path` import required by
the bundled-Python verifier.
## Verification ## Verification
@@ -108,13 +120,19 @@
noise remains. noise remains.
- `pnpm run typecheck`: passed, including after the pinned-pnpm dependency rebuild. - `pnpm run typecheck`: passed, including after the pinned-pnpm dependency rebuild.
- `pnpm exec eslint . --quiet`: passed. - `pnpm exec eslint . --quiet`: passed.
- Full unit suite: 174 files passed; 1491 tests passed, 2 skipped (1493 total). - Full unit suite after planner fixes: 174 files passed; 1495 tests passed,
2 skipped (1497 total).
- Pressure suite: 1 file and 1 test passed. - Pressure suite: 1 file and 1 test passed.
- Focused migration/resource suite: 2 files and 9 tests passed, including the - Focused post-review suite: 3 files and 33 tests passed for migration,
valid-v2 no-legacy-read/no-generated-Agent-delete case. 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.
- Electron E2E: 3 tests passed for first Pi conversation latency/submit ordering, - Electron E2E: 3 tests passed for first Pi conversation latency/submit ordering,
Pi product UI, and bundled Skill project configuration. Pi product UI, and bundled Skill project configuration.
- `pnpm run build:vite`: passed (Renderer, Electron Main, Preload, and utility). - `pnpm run build:vite`: passed (Renderer, Electron Main, Preload, and utility).
- `node scripts/verify-bundled-python.mjs --root resources/python/win32-x64
--target win32-x64`: passed for Python 3.12.13 and stdlib after restoring the
required `node:path` import.
- Windows Pi bundle: passed with 130 production packages and 6 runtime assets. - Windows Pi bundle: passed with 130 production packages and 6 runtime assets.
- Staged Pi probe: local sessions, shell, abort, and concurrency passed; decision - Staged Pi probe: local sessions, shell, abort, and concurrency passed; decision
intentionally remained `partial-pass` / `incomplete` because real Provider intentionally remained `partial-pass` / `incomplete` because real Provider
@@ -128,10 +146,18 @@
- Product-package Pi probe: local surface passed; cold p95 638 ms and warm p95 - Product-package Pi probe: local surface passed; cold p95 638 ms and warm p95
626 ms. Decision intentionally remained `partial-pass` / `incomplete` because 626 ms. Decision intentionally remained `partial-pass` / `incomplete` because
the Provider matrix has `realTurnVerified: false`. the Provider matrix has `realTurnVerified: false`.
- Zero-residue scans passed for source/package metadata, `/api/opencode`, Windows - Repository-wide zero-residue scan has no unclassified hit. Production hits are
artifact paths, ASAR package names, and old runtime resource roots. The scan limited to migration paths, persisted compatibility identifiers, packaging
separately categorized the upstream Pi Provider adapter filenames described exclusion, and the one-time notice; tests contain migration fixtures and
in Outcome rather than treating their text as a legacy runtime residue. negative assertions; old task/evidence records remain history. Canonical
project-memory descriptions are explicitly listed below for PI-160 promotion,
as required by feature-worktree ownership. The Windows artifact/ASAR scan also
separately categorizes the upstream Pi Provider adapter filenames described in
Outcome rather than treating their text as a legacy runtime residue.
- Planner review of `61fede2..5a275b9` returned Standards/Spec Needs Fix for four
P1s: unknown Agent backup, silently disconnected session observation, repo-wide
residue, and the Python verifier import. The current post-review changes address
all four and await incremental planner re-review.
- `git diff --check`: passed; only the repository's existing LF-to-CRLF warnings - `git diff --check`: passed; only the repository's existing LF-to-CRLF warnings
were emitted. were emitted.
- macOS x64/arm64 validation was explicitly skipped and is not reported as Pass. - macOS x64/arm64 validation was explicitly skipped and is not reported as Pass.

View File

@@ -34,9 +34,10 @@ Use the exact pnpm version pinned by `packageManager` in `package.json`.
## Agent and project configuration ## Agent and project configuration
- Project configuration is authoritative in `.niancode/project.json`. - Project configuration is authoritative in `.niancode/project.json`.
- Agent definitions and generated `.opencode/agent/` files are project-owned. - Agent definitions are project-owned in `.niancode/project.json`; Pi runtime
state must not become an alternative source of project configuration.
- Stable Agent ids preserve sessions; display names are editable. - Stable Agent ids preserve sessions; display names are editable.
- Do not refactor Agent templates, prompts, or OpenCode structure as an incidental part of unrelated work. - Do not refactor Agent templates, prompts, or the Coding/Pi structure as an incidental part of unrelated work.
## Verification ## Verification

View File

@@ -1,10 +1,12 @@
import { import {
copyFile, copyFile,
mkdir, mkdir,
readdir,
readFile, readFile,
rm, rm,
unlink, unlink,
} from 'node:fs/promises'; } from 'node:fs/promises';
import type { Dirent } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import type { ProductModelRef } from '../coding-runtime/contracts'; import type { ProductModelRef } from '../coding-runtime/contracts';
import { import {
@@ -93,12 +95,22 @@ async function snapshotLegacyAgentFiles(
path.basename(entry.relativePath), path.basename(entry.relativePath),
entry.content, entry.content,
])); ]));
let entries: Dirent[];
try {
entries = await readdir(agentDirectory, { withFileTypes: true });
} catch (error) {
if (isMissing(error)) return [];
throw error;
}
const snapshots: AgentFileSnapshot[] = []; const snapshots: AgentFileSnapshot[] = [];
for (const [fileName, expectedContent] of expected) { for (const entry of entries
.filter((candidate) => candidate.isFile())
.sort((left, right) => left.name.localeCompare(right.name))) {
const fileName = entry.name;
const filePath = path.join(agentDirectory, fileName); const filePath = path.join(agentDirectory, fileName);
const content = await readOptionalText(filePath); const content = await readOptionalText(filePath);
if (content === null) continue; if (content === null) continue;
const generated = expectedContent === content; const generated = expected.get(fileName) === content;
if (generated) { if (generated) {
snapshots.push({ fileName, filePath, content, generated }); snapshots.push({ fileName, filePath, content, generated });
continue; continue;

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env node #!/usr/bin/env node
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { getPythonTarget, PYTHON_VERSION } from './bundled-python-manifest.mjs'; import { getPythonTarget, PYTHON_VERSION } from './bundled-python-manifest.mjs';
function readOption(name) { function readOption(name) {

View File

@@ -6,6 +6,7 @@ import {
useUserProfileStore, useUserProfileStore,
} from '@/stores/user-profile'; } from '@/stores/user-profile';
import type { RawMessage } from '@/types/chat'; import type { RawMessage } from '@/types/chat';
import type { ConversationSnapshot } from '@/types/coding-conversation';
import type { import type {
AgentSessionData, AgentSessionData,
AgentSessionMessage, AgentSessionMessage,
@@ -338,14 +339,7 @@ async function flushUpload(key: string): Promise<void> {
} }
} }
/** Queue a completed local session snapshot without blocking the chat turn. */ function queueAgentSessionData(data: AgentSessionData): void {
export function queueAgentSessionSync(
projectId: string,
sessionId: string,
messages: readonly RawMessage[],
): void {
const data = buildAgentSessionData(projectId, sessionId, messages);
if (!data) return;
const userId = getCurrentAccountKey(); const userId = getCurrentAccountKey();
if (!userId) return; if (!userId) return;
@@ -356,6 +350,44 @@ export function queueAgentSessionSync(
scheduleUpload(key, SESSION_UPLOAD_DEBOUNCE_MS); scheduleUpload(key, SESSION_UPLOAD_DEBOUNCE_MS);
} }
/** Queue a completed local session snapshot without blocking the chat turn. */
export function queueAgentSessionSync(
projectId: string,
sessionId: string,
messages: readonly RawMessage[],
): void {
const data = buildAgentSessionData(projectId, sessionId, messages);
if (!data) return;
queueAgentSessionData(data);
}
/** 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 [];
return [{
id: node.id,
role: node.role,
content: node.blocks.flatMap((block) => (
block.kind === 'text' ? [{ type: 'text', text: block.text }] : []
)),
isError: node.status === 'error' || node.status === 'aborted',
} satisfies RawMessage];
});
const settledAt = snapshot.run.settledAt;
const updatedAt = typeof settledAt === 'number' && Number.isFinite(settledAt)
? new Date(settledAt).toISOString()
: new Date().toISOString();
const data = buildAgentSessionData(
snapshot.conversation.projectId,
snapshot.conversation.id,
messages,
updatedAt,
);
if (!data?.messages.some((message) => message.role === 'assistant')) return;
queueAgentSessionData(data);
}
/** Flush persisted session snapshots after login or profile bootstrap. */ /** Flush persisted session snapshots after login or profile bootstrap. */
export async function flushPendingAgentSessionSync(): Promise<void> { export async function flushPendingAgentSessionSync(): Promise<void> {
hydratePendingUploads(); hydratePendingUploads();

View File

@@ -7,6 +7,7 @@ import {
type ConversationReducerState, type ConversationReducerState,
} from '../../shared/coding-conversation-reducer'; } from '../../shared/coding-conversation-reducer';
import { AppError } from '@/lib/error-model'; import { AppError } from '@/lib/error-model';
import { queueCodingConversationSessionSync } from '@/lib/agent-session-sync';
import { import {
getCodingConversationSnapshot, getCodingConversationSnapshot,
openCodingConversationEvents, openCodingConversationEvents,
@@ -55,6 +56,7 @@ interface CodingConversationStoreDependencies {
openEvents(conversationId?: string): Promise<EventSource>; openEvents(conversationId?: string): Promise<EventSource>;
submitPrompt(input: SubmitCodingConversationPromptInput): Promise<PromptAcceptance>; submitPrompt(input: SubmitCodingConversationPromptInput): Promise<PromptAcceptance>;
recover(conversationId: string): Promise<void>; recover(conversationId: string): Promise<void>;
queueSettledSessionSync(snapshot: ConversationSnapshot): void;
createId(kind: 'request' | 'node'): string; createId(kind: 'request' | 'node'): string;
preparationTimeoutMs: number; preparationTimeoutMs: number;
} }
@@ -277,6 +279,7 @@ function defaultDependencies(): CodingConversationStoreDependencies {
openEvents: openCodingConversationEvents, openEvents: openCodingConversationEvents,
submitPrompt: submitCodingConversationPrompt, submitPrompt: submitCodingConversationPrompt,
recover: recoverCodingConversation, recover: recoverCodingConversation,
queueSettledSessionSync: queueCodingConversationSessionSync,
createId: () => crypto.randomUUID(), createId: () => crypto.randomUUID(),
preparationTimeoutMs: 10_000, preparationTimeoutMs: 10_000,
}; };
@@ -332,6 +335,26 @@ function validPatchBatchRange(event: CodingConversationPatchBatchEvent): boolean
)); ));
} }
function completesOrdinaryPrompt(
event: CodingConversationPatchBatchEvent,
current: ConversationSnapshot,
): boolean {
let mode = current.run.mode;
let observedActiveRun = current.run.status !== 'idle';
for (const item of event.items) {
if (item.patch.op !== 'run.state') continue;
if (item.patch.run.mode) mode = item.patch.run.mode;
if (item.patch.run.status !== 'idle') observedActiveRun = true;
if (observedActiveRun
&& mode === 'prompt'
&& item.patch.run.status === 'idle'
&& item.patch.run.terminalReason === 'completed') {
return true;
}
}
return false;
}
export function createCodingConversationStore( export function createCodingConversationStore(
dependencies: Partial<CodingConversationStoreDependencies> = {}, dependencies: Partial<CodingConversationStoreDependencies> = {},
): StoreApi<CodingConversationStoreState> { ): StoreApi<CodingConversationStoreState> {
@@ -892,7 +915,9 @@ export function createCodingConversationStore(
} }
return; return;
} }
const shouldSyncSettledSession = completesOrdinaryPrompt(event, currentSnapshot);
let recover = false; let recover = false;
let applied = false;
set((state) => { set((state) => {
const current = state.entriesByConversationId[event.conversationId] ?? emptyEntry(); const current = state.entriesByConversationId[event.conversationId] ?? emptyEntry();
let reducer = current.reducer; let reducer = current.reducer;
@@ -911,6 +936,7 @@ export function createCodingConversationStore(
} }
} }
if (reducer === current.reducer) return state; if (reducer === current.reducer) return state;
applied = true;
const incomingMessages = event.items.flatMap((item) => ( const incomingMessages = event.items.flatMap((item) => (
item.patch.op === 'message.upsert' ? [item.patch.node] : [] item.patch.op === 'message.upsert' ? [item.patch.node] : []
)); ));
@@ -950,6 +976,9 @@ export function createCodingConversationStore(
}); });
if (recover) { if (recover) {
void get().loadSnapshot(event.conversationId, true).catch(() => undefined); void get().loadSnapshot(event.conversationId, true).catch(() => undefined);
} else if (applied && shouldSyncSettledSession) {
const settledSnapshot = selectCodingConversationSnapshot(event.conversationId)(get());
if (settledSnapshot) deps.queueSettledSessionSync(settledSnapshot);
} }
}, },
})); }));

View File

@@ -1,218 +0,0 @@
export type SessionRunQueuedPrompt = {
id: string;
text: string;
};
export type SessionRunPhase = 'idle' | 'posting' | 'running' | 'aborting';
export type SessionRunTerminalReason = 'completed' | 'aborted' | 'failed';
export type SessionRunState<TPrompt extends SessionRunQueuedPrompt = SessionRunQueuedPrompt> = {
phase: SessionRunPhase;
runId: number | null;
promptId: string | null;
queue: TPrompt[];
terminalReason: SessionRunTerminalReason | null;
error: string | null;
suppressNextAbortError: boolean;
};
export type SessionRunEvent<TPrompt extends SessionRunQueuedPrompt = SessionRunQueuedPrompt> =
| { type: 'send_started'; runId: number; promptId: string }
| { type: 'post_accepted'; runId: number }
| { type: 'remote_busy'; runId: number }
| { type: 'remote_idle'; runId: number }
| { type: 'remote_aborted'; runId: number }
| { type: 'remote_failed'; runId: number; error: string }
| { type: 'abort_requested'; runId: number }
| { type: 'abort_confirmed'; runId: number }
| { type: 'abort_failed'; runId: number; error: string }
| { type: 'queue_prompt'; prompt: TPrompt }
| { type: 'cancel_queued_prompt'; promptId: string };
export function createIdleSessionRunState<TPrompt extends SessionRunQueuedPrompt>(
queue: TPrompt[] = [],
): SessionRunState<TPrompt> {
return {
phase: 'idle',
runId: null,
promptId: null,
queue,
terminalReason: null,
error: null,
suppressNextAbortError: false,
};
}
export function isCurrentRunEvent<TPrompt extends SessionRunQueuedPrompt>(
state: SessionRunState<TPrompt>,
runId: number,
): boolean {
return state.runId === runId;
}
export function isSessionRunActive<TPrompt extends SessionRunQueuedPrompt>(
state: SessionRunState<TPrompt>,
): boolean {
return state.phase === 'posting' || state.phase === 'running';
}
export function canStartQueuedPrompt<TPrompt extends SessionRunQueuedPrompt>(
state: SessionRunState<TPrompt>,
): boolean {
return state.phase === 'idle'
&& state.terminalReason !== 'failed'
&& state.queue.length > 0;
}
export function takeNextQueuedPrompt<TPrompt extends SessionRunQueuedPrompt>(
state: SessionRunState<TPrompt>,
): {
prompt: TPrompt | null;
state: SessionRunState<TPrompt>;
} {
const [prompt, ...queue] = state.queue;
return {
prompt: prompt ?? null,
state: {
...state,
queue,
},
};
}
export function didSuppressAbortError<TPrompt extends SessionRunQueuedPrompt>(
previousState: SessionRunState<TPrompt>,
nextState: SessionRunState<TPrompt>,
): boolean {
return previousState.suppressNextAbortError
&& !nextState.suppressNextAbortError
&& previousState.phase === nextState.phase
&& previousState.runId === nextState.runId
&& previousState.promptId === nextState.promptId
&& nextState.terminalReason === previousState.terminalReason
&& nextState.error === null;
}
export function transitionSessionRunState<TPrompt extends SessionRunQueuedPrompt>(
state: SessionRunState<TPrompt>,
event: SessionRunEvent<TPrompt>,
): SessionRunState<TPrompt> {
if (event.type === 'send_started') {
return {
phase: 'posting',
runId: event.runId,
promptId: event.promptId,
queue: state.queue,
terminalReason: null,
error: null,
suppressNextAbortError: state.suppressNextAbortError,
};
}
if (event.type === 'queue_prompt') {
return {
...state,
queue: [...state.queue, event.prompt],
error: null,
};
}
if (event.type === 'cancel_queued_prompt') {
return {
...state,
queue: state.queue.filter((prompt) => prompt.id !== event.promptId),
};
}
if (!isCurrentRunEvent(state, event.runId)) {
return state;
}
if (
state.phase === 'idle'
&& state.terminalReason !== null
&& (
event.type === 'post_accepted'
|| event.type === 'remote_busy'
|| event.type === 'remote_idle'
)
) {
return state;
}
switch (event.type) {
case 'post_accepted':
return state;
case 'remote_busy':
return {
...state,
phase: 'running',
terminalReason: null,
error: null,
};
case 'remote_idle':
if (state.phase === 'posting') return state;
return {
...state,
phase: 'idle',
terminalReason: 'completed',
error: null,
suppressNextAbortError: false,
};
case 'remote_aborted':
if (state.suppressNextAbortError) {
return {
...state,
error: null,
suppressNextAbortError: false,
};
}
return {
...state,
phase: 'idle',
queue: [],
terminalReason: 'aborted',
error: null,
suppressNextAbortError: false,
};
case 'remote_failed':
return {
...state,
phase: 'idle',
queue: [],
terminalReason: 'failed',
error: event.error,
suppressNextAbortError: false,
};
case 'abort_requested':
return {
...state,
phase: 'aborting',
queue: [],
terminalReason: 'aborted',
error: null,
suppressNextAbortError: true,
};
case 'abort_confirmed':
return {
...state,
phase: 'idle',
queue: [],
terminalReason: 'aborted',
error: null,
suppressNextAbortError: false,
};
case 'abort_failed':
return {
...state,
phase: 'idle',
queue: [],
terminalReason: 'failed',
error: event.error,
suppressNextAbortError: false,
};
default:
return state;
}
}

View File

@@ -3,10 +3,12 @@ import type { RawMessage } from '@/types/chat';
import { import {
buildAgentSessionData, buildAgentSessionData,
flushPendingAgentSessionSync, flushPendingAgentSessionSync,
queueCodingConversationSessionSync,
queueAgentSessionSync, queueAgentSessionSync,
resetAgentSessionSyncForTests, resetAgentSessionSyncForTests,
sanitizeAgentSessionText, sanitizeAgentSessionText,
} from '@/lib/agent-session-sync'; } from '@/lib/agent-session-sync';
import { createProductSnapshot } from '../fixtures/coding-conversation-product-fixtures';
const getAuthStateMock = vi.hoisted(() => vi.fn()); const getAuthStateMock = vi.hoisted(() => vi.fn());
const getProfileStateMock = 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 () => { it('never uploads a pending snapshot under a different authenticated account', async () => {
queueAgentSessionSync('prj_1', 'ses_1', [ queueAgentSessionSync('prj_1', 'ses_1', [
{ id: 'user-1', role: 'user', content: '账号 A 的问题' }, { id: 'user-1', role: 'user', content: '账号 A 的问题' },

View File

@@ -138,6 +138,75 @@ function ids() {
} }
describe('coding Conversation store', () => { 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 () => { it('isolates interleaved snapshot and patch state for two Conversations', async () => {
const source = new FakeEventSource(); const source = new FakeEventSource();
const getSnapshot = vi.fn(async (conversationId: string) => snapshot(conversationId)); const getSnapshot = vi.fn(async (conversationId: string) => snapshot(conversationId));

View File

@@ -202,7 +202,7 @@ describe('coding project v1 to v2 migration', () => {
]); ]);
expect(resolveLegacyModel).toHaveBeenCalledTimes(2); expect(resolveLegacyModel).toHaveBeenCalledTimes(2);
expect(result.removedGeneratedAgents.sort()).toEqual(['no-account.md', 'unique.md']); 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( expect(JSON.parse(await readFile(
path.join(staged.projectPath, '.niancode', 'conversations.json'), path.join(staged.projectPath, '.niancode', 'conversations.json'),
'utf8', 'utf8',
@@ -215,8 +215,12 @@ describe('coding project v1 to v2 migration', () => {
path.join(result.backupDirectory, '.opencode', 'agent', 'unresolved.md'), path.join(result.backupDirectory, '.opencode', 'agent', 'unresolved.md'),
'utf8', 'utf8',
)).toBe('locally modified Agent\n'); )).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' }); 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')) expect(await readFile(path.join(staged.projectPath, '.opencode', 'skills', 'keep.md'), 'utf8'))
.toBe('keep me'); .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 () => { it('restores retryable v1 state after a corrupt metadata write and succeeds on retry', async () => {
const staged = await stageLegacyProject(); const staged = await stageLegacyProject();
const customFile = path.join(staged.projectPath, '.opencode', 'agent', 'custom.md');
await writeFile(customFile, 'unknown Agent\n', 'utf8');
let writeCount = 0; let writeCount = 0;
const corruptingWriter = vi.fn(async (filePath: string, value: unknown) => { const corruptingWriter = vi.fn(async (filePath: string, value: unknown) => {
writeCount += 1; writeCount += 1;
@@ -273,6 +279,7 @@ describe('coding project v1 to v2 migration', () => {
expect(await readFile(path.join(staged.projectPath, '.opencode', entry.relativePath), 'utf8')) expect(await readFile(path.join(staged.projectPath, '.opencode', entry.relativePath), 'utf8'))
.toBe(entry.content); .toBe(entry.content);
} }
expect(await readFile(customFile, 'utf8')).toBe('unknown Agent\n');
const retry = await migrateCodingProjectToV2(staged.projectPath, { const retry = await migrateCodingProjectToV2(staged.projectPath, {
resolveLegacyModel: async ({ legacyModel }) => legacyModel === 'legacy/unique' ? MODEL : null, 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'), path.join(staged.projectPath, '.niancode', 'conversations.json'),
'utf8', 'utf8',
))).toEqual({ schemaVersion: 2, conversations: [] }); ))).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');
}); });
}); });

View File

@@ -85,7 +85,7 @@ Content-Type: application/json
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `project_id` | string | 是 | 本地项目稳定标识;不是项目路径,也不是项目名称 | | `project_id` | string | 是 | 本地项目稳定标识;不是项目路径,也不是项目名称 |
| `session_id` | string | 是 | OpenCode 会话标识;服务端按不透明字符串保存 | | `session_id` | string | 是 | Makelore Coding Conversation 标识;服务端按不透明字符串保存 |
| `updated_at` | ISO 8601 string | 是 | 客户端生成的本次快照时间,统一为 UTC ISO 字符串 | | `updated_at` | ISO 8601 string | 是 | 客户端生成的本次快照时间,统一为 UTC ISO 字符串 |
| `messages` | array | 是 | 按本地会话顺序排列的完整问答消息 | | `messages` | array | 是 | 按本地会话顺序排列的完整问答消息 |
@@ -326,8 +326,8 @@ curl -X PUT 'https://square.nianxx.cn/api/user/agent-profile' \
如服务端需要核对客户端字段或测试,可参考: 如服务端需要核对客户端字段或测试,可参考:
- `src/lib/agent-session-sync.ts`:快照构建、过滤、本地队列、账号隔离和重试; - `src/lib/agent-session-sync.ts`:Coding Conversation 快照构建、过滤、本地队列、账号隔离和重试;
- `src/lib/agent-profile.ts`:个人资料 PUT 请求类型; - `src/lib/agent-profile.ts`:个人资料 PUT 请求类型;
- `src/stores/user-profile.ts`:将 `session_data` 伴随个人资料上传; - `src/stores/user-profile.ts`:将 `session_data` 伴随个人资料上传;
- `src/stores/opencode.ts`:普通问答完成后的后台触发点; - `src/stores/coding-conversations.ts`:普通 prompt 在实时 `agent_settled` 对应的最终 Snapshot 完成后的后台触发点;
- `tests/unit/agent-session-sync.test.ts`:过滤、上传、失败重试和跨账号保护测试。 - `tests/unit/agent-session-sync.test.ts`:过滤、上传、失败重试和跨账号保护测试。