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:
1 parent
5a275b93a7
commit
7a15b1b49c
12 files changed
+286
-247
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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`:过滤、上传、失败重试和跨账号保护测试。
|
||||
Reference in new issue
Block a user