diff --git a/.gitignore b/.gitignore index 4a52f2d..f2634ac 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ node_modules/ .pnpm-store/ .pnp.* -.opencode/node_modules/ # Build and packaged output build/ 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 2129620..7e833c3 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 @@ -8,7 +8,7 @@ - Worktree: D:\Datas\OthersProjects\makelore-pi-remove-opencode-a41c9e72 - Base commit: 61fede2b4dfed3e29a6c4e37aee73896713979e1 - Owner: codex-root -- Status: Implementation Complete; Planner Review Pending +- Status: Needs Fix Addressed; Planner Re-review Pending ## Scope @@ -77,9 +77,17 @@ bundle/smoke scripts, dependencies, and obsolete tests were removed. - Retained only the migration-owned schema-v1 normalizer and expected generated Agent comparison in `electron/coding-projects/legacy-v1.ts`. Migration keeps - exact-delete/backup/rollback/notice behavior, valid schema-v2 projects bypass - legacy reads, unrelated `.opencode` content remains inert, and no `.pi/agents` - directory is introduced. + exact-delete/backup/rollback/notice behavior: it enumerates the legacy Agent + directory, removes exact generated files, and moves modified plus unknown Agent + 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 the persisted electron-store name `opencode-projects`. The remaining source 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, not the removed OpenCode runtime/package/plugin/fallback, and remain covered by 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 @@ -108,13 +120,19 @@ noise remains. - `pnpm run typecheck`: passed, including after the pinned-pnpm dependency rebuild. - `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. -- Focused migration/resource suite: 2 files and 9 tests passed, including the - valid-v2 no-legacy-read/no-generated-Agent-delete case. +- Focused post-review suite: 3 files and 33 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. - 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). +- `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. - Staged Pi probe: local sessions, shell, abort, and concurrency passed; decision 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 626 ms. Decision intentionally remained `partial-pass` / `incomplete` because the Provider matrix has `realTurnVerified: false`. -- Zero-residue scans passed for source/package metadata, `/api/opencode`, Windows - artifact paths, ASAR package names, and old runtime resource roots. The scan - separately categorized the upstream Pi Provider adapter filenames described - in Outcome rather than treating their text as a legacy runtime residue. +- Repository-wide zero-residue scan has no unclassified hit. Production hits are + limited to migration paths, persisted compatibility identifiers, packaging + exclusion, and the one-time notice; tests contain migration fixtures and + 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 were emitted. - macOS x64/arm64 validation was explicitly skipped and is not reported as Pass. diff --git a/AGENTS.md b/AGENTS.md index 837ed63..35862e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,9 +34,10 @@ Use the exact pnpm version pinned by `packageManager` in `package.json`. ## Agent and project configuration - 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. -- 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 diff --git a/electron/coding-projects/migration.ts b/electron/coding-projects/migration.ts index ebde950..5c7fbaa 100644 --- a/electron/coding-projects/migration.ts +++ b/electron/coding-projects/migration.ts @@ -1,10 +1,12 @@ import { copyFile, mkdir, + readdir, readFile, rm, unlink, } from 'node:fs/promises'; +import type { Dirent } from 'node:fs'; import path from 'node:path'; import type { ProductModelRef } from '../coding-runtime/contracts'; import { @@ -93,12 +95,22 @@ async function snapshotLegacyAgentFiles( path.basename(entry.relativePath), entry.content, ])); + let entries: Dirent[]; + try { + entries = await readdir(agentDirectory, { withFileTypes: true }); + } catch (error) { + if (isMissing(error)) return []; + throw error; + } 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 content = await readOptionalText(filePath); if (content === null) continue; - const generated = expectedContent === content; + const generated = expected.get(fileName) === content; if (generated) { snapshots.push({ fileName, filePath, content, generated }); continue; diff --git a/scripts/verify-bundled-python.mjs b/scripts/verify-bundled-python.mjs index 940e6c3..cf95c88 100644 --- a/scripts/verify-bundled-python.mjs +++ b/scripts/verify-bundled-python.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node import { spawnSync } from 'node:child_process'; +import path from 'node:path'; import { getPythonTarget, PYTHON_VERSION } from './bundled-python-manifest.mjs'; function readOption(name) { diff --git a/src/lib/agent-session-sync.ts b/src/lib/agent-session-sync.ts index dd9ea45..483a5e8 100644 --- a/src/lib/agent-session-sync.ts +++ b/src/lib/agent-session-sync.ts @@ -6,6 +6,7 @@ import { useUserProfileStore, } from '@/stores/user-profile'; import type { RawMessage } from '@/types/chat'; +import type { ConversationSnapshot } from '@/types/coding-conversation'; import type { AgentSessionData, AgentSessionMessage, @@ -338,14 +339,7 @@ async function flushUpload(key: string): Promise { } } -/** 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; +function queueAgentSessionData(data: AgentSessionData): void { const userId = getCurrentAccountKey(); if (!userId) return; @@ -356,6 +350,44 @@ export function queueAgentSessionSync( 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. */ export async function flushPendingAgentSessionSync(): Promise { hydratePendingUploads(); diff --git a/src/stores/coding-conversations.ts b/src/stores/coding-conversations.ts index 37b0b24..7ff9649 100644 --- a/src/stores/coding-conversations.ts +++ b/src/stores/coding-conversations.ts @@ -7,6 +7,7 @@ import { type ConversationReducerState, } from '../../shared/coding-conversation-reducer'; import { AppError } from '@/lib/error-model'; +import { queueCodingConversationSessionSync } from '@/lib/agent-session-sync'; import { getCodingConversationSnapshot, openCodingConversationEvents, @@ -55,6 +56,7 @@ interface CodingConversationStoreDependencies { openEvents(conversationId?: string): Promise; submitPrompt(input: SubmitCodingConversationPromptInput): Promise; recover(conversationId: string): Promise; + queueSettledSessionSync(snapshot: ConversationSnapshot): void; createId(kind: 'request' | 'node'): string; preparationTimeoutMs: number; } @@ -277,6 +279,7 @@ function defaultDependencies(): CodingConversationStoreDependencies { openEvents: openCodingConversationEvents, submitPrompt: submitCodingConversationPrompt, recover: recoverCodingConversation, + queueSettledSessionSync: queueCodingConversationSessionSync, createId: () => crypto.randomUUID(), 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( dependencies: Partial = {}, ): StoreApi { @@ -892,7 +915,9 @@ export function createCodingConversationStore( } return; } + const shouldSyncSettledSession = completesOrdinaryPrompt(event, currentSnapshot); let recover = false; + let applied = false; set((state) => { const current = state.entriesByConversationId[event.conversationId] ?? emptyEntry(); let reducer = current.reducer; @@ -911,6 +936,7 @@ export function createCodingConversationStore( } } if (reducer === current.reducer) return state; + applied = true; const incomingMessages = event.items.flatMap((item) => ( item.patch.op === 'message.upsert' ? [item.patch.node] : [] )); @@ -950,6 +976,9 @@ export function createCodingConversationStore( }); if (recover) { void get().loadSnapshot(event.conversationId, true).catch(() => undefined); + } else if (applied && shouldSyncSettledSession) { + const settledSnapshot = selectCodingConversationSnapshot(event.conversationId)(get()); + if (settledSnapshot) deps.queueSettledSessionSync(settledSnapshot); } }, })); diff --git a/src/stores/opencode-session-run-machine.ts b/src/stores/opencode-session-run-machine.ts deleted file mode 100644 index 67daa76..0000000 --- a/src/stores/opencode-session-run-machine.ts +++ /dev/null @@ -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 = { - phase: SessionRunPhase; - runId: number | null; - promptId: string | null; - queue: TPrompt[]; - terminalReason: SessionRunTerminalReason | null; - error: string | null; - suppressNextAbortError: boolean; -}; - -export type SessionRunEvent = - | { 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( - queue: TPrompt[] = [], -): SessionRunState { - return { - phase: 'idle', - runId: null, - promptId: null, - queue, - terminalReason: null, - error: null, - suppressNextAbortError: false, - }; -} - -export function isCurrentRunEvent( - state: SessionRunState, - runId: number, -): boolean { - return state.runId === runId; -} - -export function isSessionRunActive( - state: SessionRunState, -): boolean { - return state.phase === 'posting' || state.phase === 'running'; -} - -export function canStartQueuedPrompt( - state: SessionRunState, -): boolean { - return state.phase === 'idle' - && state.terminalReason !== 'failed' - && state.queue.length > 0; -} - -export function takeNextQueuedPrompt( - state: SessionRunState, -): { - prompt: TPrompt | null; - state: SessionRunState; -} { - const [prompt, ...queue] = state.queue; - return { - prompt: prompt ?? null, - state: { - ...state, - queue, - }, - }; -} - -export function didSuppressAbortError( - previousState: SessionRunState, - nextState: SessionRunState, -): 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( - state: SessionRunState, - event: SessionRunEvent, -): SessionRunState { - 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; - } -} diff --git a/tests/unit/agent-session-sync.test.ts b/tests/unit/agent-session-sync.test.ts index 312ada3..6adf545 100644 --- a/tests/unit/agent-session-sync.test.ts +++ b/tests/unit/agent-session-sync.test.ts @@ -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 的问题' }, diff --git a/tests/unit/coding-conversations-store.test.tsx b/tests/unit/coding-conversations-store.test.tsx index bf25e1a..ba440a7 100644 --- a/tests/unit/coding-conversations-store.test.tsx +++ b/tests/unit/coding-conversations-store.test.tsx @@ -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)); diff --git a/tests/unit/coding-projects-migration.test.ts b/tests/unit/coding-projects-migration.test.ts index 4f636be..0d02816 100644 --- a/tests/unit/coding-projects-migration.test.ts +++ b/tests/unit/coding-projects-migration.test.ts @@ -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'); }); }); diff --git a/个人资料会话观察同步服务端对接说明.md b/个人资料会话观察同步服务端对接说明.md index 2c2fa33..32b943b 100644 --- a/个人资料会话观察同步服务端对接说明.md +++ b/个人资料会话观察同步服务端对接说明.md @@ -85,7 +85,7 @@ Content-Type: application/json | 字段 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | | `project_id` | string | 是 | 本地项目稳定标识;不是项目路径,也不是项目名称 | -| `session_id` | string | 是 | OpenCode 会话标识;服务端按不透明字符串保存 | +| `session_id` | string | 是 | Makelore Coding Conversation 标识;服务端按不透明字符串保存 | | `updated_at` | ISO 8601 string | 是 | 客户端生成的本次快照时间,统一为 UTC ISO 字符串 | | `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/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`:过滤、上传、失败重试和跨账号保护测试。