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:
brother7 committed 2026-08-24 12:47:16 +08:00
1 parent 5a275b93a7
commit 7a15b1b49c
12 files changed
+286 -247

No files matched your search

-1
View File
@@ -2,7 +2,6 @@
node_modules/
.pnpm-store/
.pnp.*
.opencode/node_modules/
# Build and packaged output
build/
@@ -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.
+3 -2
View File
@@ -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
+14 -2
View File
@@ -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;
+1
View File
@@ -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) {
+40 -8
View File
@@ -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<void> {
}
}
/** 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<void> {
hydratePendingUploads();
+29
View File
@@ -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<EventSource>;
submitPrompt(input: SubmitCodingConversationPromptInput): Promise<PromptAcceptance>;
recover(conversationId: string): Promise<void>;
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<CodingConversationStoreDependencies> = {},
): StoreApi<CodingConversationStoreState> {
@@ -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);
}
},
}));
-218
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;
}
}
+75
View File
@@ -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));
+15 -2
View File
@@ -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');
});
});
@@ -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`:过滤、上传、失败重试和跨账号保护测试。