From 52b2467d5dfa82bd2e99e890b02bc691fffd95e8 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Sun, 23 Aug 2026 20:55:49 +0800 Subject: [PATCH] fix(coding): close PI-100 review findings --- .../20260823-pi-core-host-api-a17f6c2e.md | 14 +- electron/api/coding-composition.ts | 17 +- electron/api/coding-provider-auth.ts | 40 +++ electron/api/routes/coding-conversations.ts | 2 +- electron/api/routes/coding-files.ts | 31 +- electron/api/routes/coding-projects.ts | 36 ++- electron/api/routes/coding-route-errors.ts | 59 +++- electron/coding-projects/project-service.ts | 91 ++++-- electron/coding-runtime/contracts.ts | 1 + .../coding-runtime/conversation-service.ts | 134 +++++++-- .../in-memory-conversation-runtime.ts | 12 + electron/coding-runtime/pi/resource-loader.ts | 28 +- electron/coding-runtime/pi/runtime.ts | 59 ++-- tests/unit/coding-core-routes.test.ts | 270 +++++++++++++++++- tests/unit/coding-files-routes.test.ts | 2 +- tests/unit/pi-runtime-auth-recovery.test.ts | 5 +- 16 files changed, 679 insertions(+), 122 deletions(-) create mode 100644 electron/api/coding-provider-auth.ts diff --git a/.project-docs/30-worklog/tasks/20260823-pi-core-host-api-a17f6c2e.md b/.project-docs/30-worklog/tasks/20260823-pi-core-host-api-a17f6c2e.md index 5acc248..bcc23c3 100644 --- a/.project-docs/30-worklog/tasks/20260823-pi-core-host-api-a17f6c2e.md +++ b/.project-docs/30-worklog/tasks/20260823-pi-core-host-api-a17f6c2e.md @@ -8,7 +8,7 @@ - Worktree: D:\Datas\OthersProjects\makelore-pi-core-host-api-a17f6c2e - Base commit: 98bac206396198cc276c659ed988372fc5c8bc10 - Owner: codex-root -- Status: Review +- Status: In Progress ## Scope @@ -85,6 +85,18 @@ Vite build, Windows Electron coverage, documentation gates, and planner review; correct confirmed failures before completion. +## Planner Review Correction + +- Planner review of `98bac20..22b4a9f` concluded `NEEDS FIX` and kept the exact + Ready Frontier at `{PI-100}`. +- The correction scope is limited to eight confirmed contract gaps: unresolved + model recovery, session trash on delete, protected uncertain-request + tombstones, cleanup across every active-project transition, Renderer-safe + project DTOs, production authentication recovery wiring, failed-fork runtime + cleanup, and stable fixed Host error projection including storage failures. +- PI-105 remains closed; its vendor-neutral project seam and live target + `get_commands` seam were accepted structurally. + ## Outcome - Added the vendor-neutral `CodingProjectService` and diff --git a/electron/api/coding-composition.ts b/electron/api/coding-composition.ts index 4457315..ba6c9c7 100644 --- a/electron/api/coding-composition.ts +++ b/electron/api/coding-composition.ts @@ -25,7 +25,12 @@ import { createPiManagedSubagentChildOpener } from '../coding-runtime/pi/subagen import { PiSubagentScheduler } from '../coding-runtime/pi/subagent'; import { PiProcessBudget, PiWorkerPool } from '../coding-runtime/pi/worker-pool'; import { getProviderService } from '../services/providers/provider-service'; +import { + isCodingProviderAuthenticationError, + refreshCodingProviderCredential, +} from './coding-provider-auth'; import { createCodingProductHost, type CodingProductComposition } from './coding-product-services'; +import { archivePiConversationSession } from '../coding-runtime/pi/resource-loader'; export interface CodingCompositionPaths { executablePath: string; @@ -128,6 +133,8 @@ export function createCodingComposition( buildPiProviderCatalog(await loadProviderInput()), model, ), + isAuthenticationError: isCodingProviderAuthenticationError, + refreshCredential: refreshCodingProviderCredential, resolveImages: async (refs) => await Promise.all(refs.map(async ({ attachmentId }) => { const record = await attachments.read(attachmentId); return { @@ -155,7 +162,15 @@ export function createCodingComposition( ]); }, }); - const conversations = new CodingConversationService(projects, runtime); + const conversations = new CodingConversationService(projects, runtime, { + archiveSession: async ({ projectId, sessionKey }) => { + await archivePiConversationSession({ + userDataDir: options.paths.userDataDir, + projectId, + sessionKey, + }); + }, + }); const host = createCodingProductHost({ projects, productTools, diff --git a/electron/api/coding-provider-auth.ts b/electron/api/coding-provider-auth.ts new file mode 100644 index 0000000..455e578 --- /dev/null +++ b/electron/api/coding-provider-auth.ts @@ -0,0 +1,40 @@ +import { resolvePiProviderCredentialFromSecretStore } from '../coding-runtime/pi/provider-config'; +import { getProviderService } from '../services/providers/provider-service'; +import { + getFreshWorksSquareAIGatewayCredential, + markWorksSquareAIGatewayCredentialExpired, +} from '../services/works-square-ai-gateway'; + +const WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE = 'works_square_ai_gateway'; +const AUTHENTICATION_ERROR_PATTERN = /\b(?:401|403|unauthori[sz]ed|forbidden|authentication failed|auth failed|invalid (?:api key|credential|access token|bearer token)|(?:access |bearer )?token expired)\b/i; + +export function isCodingProviderAuthenticationError(error: unknown): boolean { + return error instanceof Error && AUTHENTICATION_ERROR_PATTERN.test(error.message); +} + +export async function refreshCodingProviderCredential(accountId: string): Promise { + const providerService = getProviderService(); + const account = await providerService.getAccount(accountId); + if (!account?.enabled) throw new Error('Provider account is unavailable'); + + if (account.metadata?.worksSquareCredentialMode === WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE) { + markWorksSquareAIGatewayCredentialExpired(); + const credential = await getFreshWorksSquareAIGatewayCredential(); + if (!credential) throw new Error('Provider credential refresh failed'); + await providerService.updateAccount(account.id, { + baseUrl: credential.oneApiBaseUrl, + metadata: { + ...account.metadata, + worksSquareCredentialExpiresAt: credential.expiresAt === null + ? undefined + : new Date(credential.expiresAt).toISOString(), + }, + }, credential.accessToken); + return; + } + + const current = await resolvePiProviderCredentialFromSecretStore(account); + if (!current && account.authMode !== 'local') { + throw new Error('Provider credential is unavailable'); + } +} diff --git a/electron/api/routes/coding-conversations.ts b/electron/api/routes/coding-conversations.ts index 24547c4..7d64703 100644 --- a/electron/api/routes/coding-conversations.ts +++ b/electron/api/routes/coding-conversations.ts @@ -56,7 +56,7 @@ export async function handleCodingConversationRoutes( sendJson(res, 503, { success: false, code: 'CODING_CORE_UNAVAILABLE', - error: 'Coding services are unavailable', + error: '本地编程服务暂时不可用。', }); return true; } diff --git a/electron/api/routes/coding-files.ts b/electron/api/routes/coding-files.ts index a0ee512..7e91a2d 100644 --- a/electron/api/routes/coding-files.ts +++ b/electron/api/routes/coding-files.ts @@ -2,33 +2,22 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import type { HostApiContext } from '../context'; import { CodingProductHostError } from '../coding-product-services'; import { sendJson } from '../route-utils'; +import { sendFixedCodingError } from './coding-route-errors'; function unavailable(res: ServerResponse): void { - sendJson(res, 503, { - success: false, - code: 'CODING_PRODUCT_TOOLS_UNAVAILABLE', - error: 'Coding product tools are unavailable', - }); + sendFixedCodingError(res, 503, 'CODING_PRODUCT_TOOLS_UNAVAILABLE'); } function serviceError(res: ServerResponse, error: unknown): void { if (error instanceof CodingProductHostError) { - sendJson(res, error.status, { - success: false, - code: error.code, - error: error.message, - }); + sendFixedCodingError(res, error.status, error.code); return; } const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : ''; if (code === 'ENOENT') { - sendJson(res, 404, { - success: false, - code: 'CODING_FILE_NOT_FOUND', - error: 'Project file does not exist', - }); + sendFixedCodingError(res, 404, 'CODING_FILE_NOT_FOUND'); return; } const message = error instanceof Error ? error.message : ''; @@ -44,18 +33,10 @@ function serviceError(res: ServerResponse, error: unknown): void { 'Project file is not valid UTF-8 text', ]); if (knownInputError.has(message)) { - sendJson(res, 400, { - success: false, - code: 'CODING_FILE_REQUEST_INVALID', - error: message, - }); + sendFixedCodingError(res, 400, 'CODING_FILE_REQUEST_INVALID'); return; } - sendJson(res, 500, { - success: false, - code: 'CODING_PRODUCT_TOOL_FAILED', - error: 'Coding product request failed', - }); + sendFixedCodingError(res, 500, 'CODING_PRODUCT_TOOL_FAILED'); } export async function handleCodingFileRoutes( diff --git a/electron/api/routes/coding-projects.ts b/electron/api/routes/coding-projects.ts index 9b05192..f94a954 100644 --- a/electron/api/routes/coding-projects.ts +++ b/electron/api/routes/coding-projects.ts @@ -1,5 +1,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import type { ProjectType } from '../../../shared/project-config'; +import type { CodingProjectConfigSnapshot } from '../../coding-projects/project-service'; +import type { CodingProject } from '../../coding-projects/project-store'; import type { HostApiContext } from '../context'; import { parseJsonBody, sendJson, sendNoContent } from '../route-utils'; import { sendCodingRouteError } from './coding-route-errors'; @@ -9,6 +11,19 @@ function isProjectRoute(pathname: string): boolean { || pathname.startsWith('/api/coding/projects/'); } +function publicProject(project: CodingProject | null): Omit | null { + if (!project) return null; + const { path: _path, ...safe } = project; + return safe; +} + +function publicProjectSnapshot(snapshot: CodingProjectConfigSnapshot) { + return { + ...snapshot, + project: publicProject(snapshot.project), + }; +} + export async function handleCodingProjectRoutes( req: IncomingMessage, res: ServerResponse, @@ -22,7 +37,7 @@ export async function handleCodingProjectRoutes( sendJson(res, 503, { success: false, code: 'CODING_CORE_UNAVAILABLE', - error: 'Coding services are unavailable', + error: '本地编程服务暂时不可用。', }); return true; } @@ -33,12 +48,15 @@ export async function handleCodingProjectRoutes( projects.listProjects(), projects.getActiveProject(), ]); - sendJson(res, 200, { projects: items, activeProjectId: activeProject?.id ?? null }); + sendJson(res, 200, { + projects: items.map((project) => publicProject(project)), + activeProjectId: activeProject?.id ?? null, + }); return true; } if (url.pathname === '/api/coding/projects/open' && req.method === 'POST') { const body = await parseJsonBody<{ projectPath?: string }>(req); - sendJson(res, 200, { project: await projects.openProject(body.projectPath ?? '') }); + sendJson(res, 200, { project: publicProject(await projects.openProject(body.projectPath ?? '')) }); return true; } if (url.pathname === '/api/coding/projects/create' && req.method === 'POST') { @@ -48,7 +66,7 @@ export async function handleCodingProjectRoutes( projectName?: string; projectType?: ProjectType; }>(req); - sendJson(res, 201, { snapshot: await projects.createProject(body) }); + sendJson(res, 201, { snapshot: publicProjectSnapshot(await projects.createProject(body)) }); return true; } if (url.pathname === '/api/coding/projects/remove' && req.method === 'POST') { @@ -58,24 +76,26 @@ export async function handleCodingProjectRoutes( return true; } if (url.pathname === '/api/coding/projects/active' && req.method === 'GET') { - sendJson(res, 200, { project: await projects.getActiveProject() }); + sendJson(res, 200, { project: publicProject(await projects.getActiveProject()) }); return true; } if (url.pathname === '/api/coding/projects/active' && req.method === 'POST') { const body = await parseJsonBody<{ projectId?: string }>(req); - sendJson(res, 200, { project: await projects.setActiveProject(body.projectId ?? '') }); + sendJson(res, 200, { project: publicProject(await projects.setActiveProject(body.projectId ?? '')) }); return true; } if (url.pathname === '/api/coding/projects/config' && req.method === 'GET') { sendJson(res, 200, { - snapshot: await projects.getConfig(url.searchParams.get('projectId')?.trim() || undefined), + snapshot: publicProjectSnapshot( + await projects.getConfig(url.searchParams.get('projectId')?.trim() || undefined), + ), }); return true; } if (url.pathname === '/api/coding/projects/config' && req.method === 'PUT') { const body = await parseJsonBody<{ projectId?: string; config?: unknown }>(req); sendJson(res, 200, { - snapshot: await projects.saveConfig(body.projectId ?? '', body.config), + snapshot: publicProjectSnapshot(await projects.saveConfig(body.projectId ?? '', body.config)), }); return true; } diff --git a/electron/api/routes/coding-route-errors.ts b/electron/api/routes/coding-route-errors.ts index f601a18..afc503e 100644 --- a/electron/api/routes/coding-route-errors.ts +++ b/electron/api/routes/coding-route-errors.ts @@ -3,27 +3,72 @@ import { CodingProjectServiceError } from '../../coding-projects/project-service import { CodingConversationServiceError } from '../../coding-runtime/conversation-service'; import { sendJson } from '../route-utils'; +const FIXED_CODING_ERROR_MESSAGES: Readonly> = { + CODING_ACTIVE_PROJECT_REQUIRED: '请先选择一个编程项目。', + CODING_AGENT_NOT_FOUND: '当前伙伴不可用,请重新选择。', + CODING_AGENT_ID_IMMUTABLE: '已有伙伴标识不能修改。', + CODING_CONVERSATION_NOT_FOUND: '指定的对话不存在。', + CODING_CONVERSATION_REQUEST_INVALID: '对话请求无效,请检查输入。', + CODING_FILE_NOT_FOUND: '指定的项目文件不存在。', + CODING_FILE_REQUEST_INVALID: '文件请求无效,请检查输入。', + CODING_INTERACTION_REQUEST_INVALID: '交互响应无效,请重试。', + CODING_KNOWLEDGE_ALREADY_EXISTS: '同名知识文件已存在。', + CODING_KNOWLEDGE_REQUEST_INVALID: '知识文件无效,请检查后重试。', + CODING_MIGRATION_MODEL_REQUIRED: '请先为该对话选择一个可用模型。', + CODING_MODEL_UNAVAILABLE: '所选模型当前不可用,请重新选择。', + CODING_PROJECT_ALREADY_EXISTS: '该编程项目已经存在。', + CODING_PROJECT_CONFIG_INVALID: '项目配置无效,请检查后重试。', + CODING_PROJECT_IDENTITY_IMMUTABLE: '项目创建标识不能修改。', + CODING_PROJECT_NOT_FOUND: '指定的编程项目不存在。', + CODING_PROJECT_REQUEST_INVALID: '项目请求无效,请检查输入。', + CODING_PROJECT_TYPE_IMMUTABLE: '项目类型创建后不能修改。', + CODING_PRODUCT_TOOL_FAILED: '项目工具执行失败,请重试。', + CODING_PRODUCT_TOOLS_UNAVAILABLE: '项目工具暂时不可用。', + CODING_PROVIDER_AUTH_REQUIRED: 'Provider 凭证不可用,请修复账号后重试。', + CODING_REQUEST_CAPACITY_EXCEEDED: '本地请求队列已满,请稍后重试。', + CODING_REQUEST_ID_CONFLICT: '该请求标识已用于不同内容,请使用新的标识。', + CODING_REQUEST_UNCERTAIN: '请求状态无法确认,请先核对对话后再决定是否重试。', + CODING_RUNTIME_PROTOCOL_ERROR: '本地 Agent 通信异常,请执行恢复。', + CODING_RUNTIME_READY_TIMEOUT: '本地 Agent 启动超时,请执行恢复。', + CODING_RUNTIME_START_FAILED: '本地 Agent 启动失败,请执行恢复。', + CODING_RUNTIME_UNAVAILABLE: '本地编程运行时暂时不可用。', + CODING_SESSION_UNREADABLE: '对话会话无法读取,原文件已保留。', + CODING_STORAGE_WRITE_FAILED: '本地数据写入失败,请检查存储后重试。', +}; + +function fixedMessage(code: string): string { + return FIXED_CODING_ERROR_MESSAGES[code] ?? '编程操作失败,请重试。'; +} + +export function sendFixedCodingError( + res: ServerResponse, + status: number, + code: string, +): void { + sendJson(res, status, { + success: false, + code, + error: fixedMessage(code), + }); +} + export function sendCodingRouteError(res: ServerResponse, error: unknown): void { if (error instanceof CodingProjectServiceError || error instanceof CodingConversationServiceError) { - sendJson(res, error.status, { - success: false, - code: error.code, - error: error.message, - }); + sendFixedCodingError(res, error.status, error.code); return; } if (error instanceof SyntaxError) { sendJson(res, 400, { success: false, code: 'CODING_REQUEST_INVALID', - error: 'Request JSON is invalid', + error: '请求内容无效,请检查后重试。', }); return; } sendJson(res, 500, { success: false, code: 'CODING_REQUEST_FAILED', - error: 'Coding request failed', + error: '编程操作失败,请重试。', }); } diff --git a/electron/coding-projects/project-service.ts b/electron/coding-projects/project-service.ts index 74461c1..eea87db 100644 --- a/electron/coding-projects/project-service.ts +++ b/electron/coding-projects/project-service.ts @@ -22,7 +22,7 @@ const MAX_KNOWLEDGE_FILE_BYTES = 25 * 1024 * 1024; export class CodingProjectServiceError extends Error { constructor( - readonly status: 400 | 404 | 409, + readonly status: 400 | 404 | 409 | 500, readonly code: string, message: string, ) { @@ -31,6 +31,15 @@ export class CodingProjectServiceError extends Error { } } +function storageFailure(error: unknown): never { + if (error instanceof CodingProjectServiceError) throw error; + throw new CodingProjectServiceError( + 500, + 'CODING_STORAGE_WRITE_FAILED', + 'Coding project data could not be persisted', + ); +} + export interface CreateCodingProjectRequest { projectPath?: string; parentPath?: string; @@ -47,6 +56,7 @@ export interface CodingProjectConfigSnapshot { export interface CodingProjectServiceOptions { onResourcesChanged?(project: CodingProject): Promise | void; onProjectDeactivated?(project: CodingProject): Promise | void; + writeConfig?: typeof writeCodingProjectConfigV2; } function requiredAbsolutePath(value: string | undefined, label: string): string { @@ -85,6 +95,7 @@ export class CodingProjectService { string, ReturnType >(); + private activeTransitionTail = Promise.resolve(); constructor( private readonly store: CodingProjectStore, @@ -126,7 +137,14 @@ export class CodingProjectService { if (!entry?.isDirectory()) { throw new CodingProjectServiceError(400, 'CODING_PROJECT_REQUEST_INVALID', 'Project path is not a directory'); } - return await this.store.openFolder(resolved); + try { + return await this.transitionActiveProject(async () => { + const project = await this.store.openFolder(resolved); + return { project, value: project }; + }); + } catch (error) { + storageFailure(error); + } } async createProject(input: CreateCodingProjectRequest): Promise { @@ -146,19 +164,26 @@ export class CodingProjectService { throw new CodingProjectServiceError(409, 'CODING_PROJECT_ALREADY_EXISTS', 'Project directory already exists'); } } - await mkdir(projectPath, { recursive: true }); try { - const { project, config } = await createLocalCodingProject({ - projectPath, - ...(input.projectType ? { projectType: input.projectType } : {}), - }, this.store); - return { project, config, knowledgeFiles: [] }; + await mkdir(projectPath, { recursive: true }); + } catch (error) { + storageFailure(error); + } + try { + return await this.transitionActiveProject(async () => { + const { project, config } = await createLocalCodingProject({ + projectPath, + ...(input.projectType ? { projectType: input.projectType } : {}), + }, this.store); + const snapshot = { project, config, knowledgeFiles: [] }; + return { project, value: snapshot }; + }); } catch (error) { if (error instanceof CodingProjectServiceError) throw error; if (error instanceof Error && error.message === 'Coding project configuration already exists') { throw new CodingProjectServiceError(409, 'CODING_PROJECT_ALREADY_EXISTS', 'Coding project already exists'); } - throw error; + storageFailure(error); } } @@ -166,7 +191,11 @@ export class CodingProjectService { const project = await this.getProject(projectId); const active = await this.store.getActiveProject(); if (active?.id === project.id) await this.options.onProjectDeactivated?.(project); - await this.store.removeProject(project.id); + try { + await this.store.removeProject(project.id); + } catch (error) { + storageFailure(error); + } this.conversationStores.delete(project.path); } @@ -180,9 +209,14 @@ export class CodingProjectService { 'Coding project configuration is unavailable', ); } - const active = await this.store.getActiveProject(); - if (active && active.id !== project.id) await this.options.onProjectDeactivated?.(active); - return (await this.store.setActiveProject(project.id)) as CodingProject; + try { + return await this.transitionActiveProject(async () => { + const activated = (await this.store.setActiveProject(project.id)) as CodingProject; + return { project: activated, value: activated }; + }); + } catch (error) { + storageFailure(error); + } } async getConfig(projectId?: string): Promise { @@ -208,11 +242,15 @@ export class CodingProjectService { try { next = normalizeCodingProjectConfigV2(value); assertStableConfig(current.config, next); - await writeCodingProjectConfigV2(current.project.path, next); } catch (error) { if (error instanceof CodingProjectServiceError) throw error; throw new CodingProjectServiceError(400, 'CODING_PROJECT_CONFIG_INVALID', 'Coding project configuration is invalid'); } + try { + await (this.options.writeConfig ?? writeCodingProjectConfigV2)(current.project.path, next); + } catch (error) { + storageFailure(error); + } await this.options.onResourcesChanged?.(current.project); return { project: current.project, @@ -241,14 +279,18 @@ export class CodingProjectService { throw new CodingProjectServiceError(400, 'CODING_KNOWLEDGE_REQUEST_INVALID', 'Knowledge file exceeds 25 MB'); } const directory = path.join(project.path, 'knowledge'); - await mkdir(directory, { recursive: true }); + try { + await mkdir(directory, { recursive: true }); + } catch (error) { + storageFailure(error); + } try { await writeFile(path.join(directory, fileName), content, { flag: 'wx' }); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'EEXIST') { throw new CodingProjectServiceError(409, 'CODING_KNOWLEDGE_ALREADY_EXISTS', 'Knowledge file already exists'); } - throw error; + storageFailure(error); } await this.options.onResourcesChanged?.(project); return await this.listKnowledgeFiles(project.path); @@ -287,4 +329,21 @@ export class CodingProjectService { throw error; } } + + private transitionActiveProject(operation: () => Promise<{ + project: CodingProject; + value: T; + }>): Promise { + const execute = async () => { + const previous = await this.store.getActiveProject(); + const result = await operation(); + if (previous && previous.id !== result.project.id) { + await this.options.onProjectDeactivated?.(previous); + } + return result.value; + }; + const result = this.activeTransitionTail.then(execute, execute); + this.activeTransitionTail = result.then(() => undefined, () => undefined); + return result; + } } diff --git a/electron/coding-runtime/contracts.ts b/electron/coding-runtime/contracts.ts index e1c751c..49cb690 100644 --- a/electron/coding-runtime/contracts.ts +++ b/electron/coding-runtime/contracts.ts @@ -415,6 +415,7 @@ export interface CodingConversationRuntime { steer(input: QueueMessageInput): Promise; followUp(input: QueueMessageInput): Promise; abort(conversationId: string): Promise; + validateModel(model: ProductModelRef): Promise; setModel(input: SetConversationModelInput): Promise; setThinking(input: SetThinkingLevelInput): Promise; compact(conversationId: string): Promise; diff --git a/electron/coding-runtime/conversation-service.ts b/electron/coding-runtime/conversation-service.ts index a2a3000..c8a3d45 100644 --- a/electron/coding-runtime/conversation-service.ts +++ b/electron/coding-runtime/conversation-service.ts @@ -27,7 +27,7 @@ const ATTACHMENT_ID_PATTERN = /^[A-Za-z0-9-]{1,64}$/; export class CodingConversationServiceError extends Error { constructor( - readonly status: 400 | 404 | 409 | 503, + readonly status: 400 | 404 | 409 | 500 | 503, readonly code: string, message: string, ) { @@ -41,6 +41,14 @@ export type CodingPromptAcceptance = PromptAcceptance; interface AcceptanceRecord { fingerprint: string; flight: Promise; + state: { value: 'protected' | 'settled' }; +} + +export interface CodingConversationServiceOptions { + archiveSession?(input: { + projectId: string; + sessionKey: string; + }): Promise; } export type CodingConversationStreamEvent = @@ -78,8 +86,17 @@ function runtimeError(error: unknown): never { ? error.publicError as { code?: unknown; message?: unknown } : null; if (publicError && typeof publicError.code === 'string') { + const status = publicError.code === 'CODING_CONVERSATION_NOT_FOUND' + ? 404 + : publicError.code === 'CODING_STORAGE_WRITE_FAILED' + ? 500 + : publicError.code === 'CODING_RUNTIME_START_FAILED' + || publicError.code === 'CODING_RUNTIME_READY_TIMEOUT' + || publicError.code === 'CODING_RUNTIME_PROTOCOL_ERROR' + ? 503 + : 409; throw new CodingConversationServiceError( - publicError.code === 'CODING_CONVERSATION_NOT_FOUND' ? 404 : 409, + status, publicError.code, typeof publicError.message === 'string' ? publicError.message : 'Coding runtime request failed', ); @@ -87,6 +104,21 @@ function runtimeError(error: unknown): never { throw new CodingConversationServiceError(503, 'CODING_RUNTIME_UNAVAILABLE', 'The local coding runtime is unavailable'); } +async function persist(operation: () => Promise): Promise { + try { + return await operation(); + } catch (error) { + if (error instanceof CodingConversationServiceError || error instanceof CodingProjectServiceError) { + throw error; + } + throw new CodingConversationServiceError( + 500, + 'CODING_STORAGE_WRITE_FAILED', + 'Coding data could not be persisted', + ); + } +} + function publicConversation(conversation: CodingConversationV2): CodingConversationV2 { const { piSessionId: _piSessionId, sessionKey: _sessionKey, ...safe } = conversation; return safe; @@ -135,6 +167,7 @@ export class CodingConversationService { constructor( readonly projects: CodingProjectService, readonly runtime: CodingConversationRuntime, + private readonly options: CodingConversationServiceOptions = {}, ) {} async listConversations(projectId?: string): Promise { @@ -160,12 +193,12 @@ export class CodingConversationService { if (!agent) { throw new CodingConversationServiceError(404, 'CODING_AGENT_NOT_FOUND', 'Coding project Agent does not exist'); } - const created = await this.projects.conversationStore(project.path).create({ + const created = await persist(() => this.projects.conversationStore(project.path).create({ agentId, title: requiredString(input.title, 'Conversation title', MAX_TITLE_LENGTH), model: agent.model, modelResolution: agent.modelResolution, - }); + })); return publicConversation(created); } @@ -189,7 +222,7 @@ export class CodingConversationService { if (patch.unread !== undefined && typeof patch.unread !== 'boolean') { throw new CodingConversationServiceError(400, 'CODING_CONVERSATION_REQUEST_INVALID', 'Conversation unread state is invalid'); } - const updated = await this.projects.conversationStore(project.path).patchMetadata(conversationId, { + const updated = await persist(() => this.projects.conversationStore(project.path).patchMetadata(conversationId, { ...(patch.title !== undefined ? { title: requiredString(patch.title, 'Conversation title', MAX_TITLE_LENGTH) } : {}), @@ -197,18 +230,19 @@ export class CodingConversationService { ? { archivedAt: patch.archived ? new Date().toISOString() : null } : {}), ...(typeof patch.unread === 'boolean' ? { unread: patch.unread } : {}), - }); + })); return publicConversation(updated); } async deleteConversation(conversationId: string): Promise { - const { project } = await this.projects.findActiveConversation(conversationId); + const { project, conversation } = await this.projects.findActiveConversation(conversationId); try { await this.runtime.dispose(conversationId); } catch (error) { runtimeError(error); } - await this.projects.conversationStore(project.path).delete(conversationId); + await this.archiveSession(project.id, conversation.sessionKey); + await persist(() => this.projects.conversationStore(project.path).delete(conversationId)); this.prepareFlights.delete(conversationId); for (const key of [...this.acceptances.keys()]) { if (key.startsWith(`${conversationId}\u0000`)) this.acceptances.delete(key); @@ -263,18 +297,26 @@ export class CodingConversationService { if (prior.fingerprint !== fingerprint) { throw new CodingConversationServiceError(409, 'CODING_REQUEST_ID_CONFLICT', 'Client request id was already used'); } + if (prior.state.value === 'settled') { + this.acceptances.delete(key); + this.acceptances.set(key, prior); + } return await prior.flight; } + this.reserveAcceptanceSlot(); + const acceptanceState: AcceptanceRecord['state'] = { value: 'protected' }; const flight = (async (): Promise => { try { await this.ensurePrepared(conversationId); - return await this.runtime.prompt({ + const acceptance = await this.runtime.prompt({ conversationId, clientRequestId, mode, text: input.text as string, attachments: normalizedAttachments, }); + acceptanceState.value = 'settled'; + return acceptance; } catch (error) { const publicCode = error && typeof error === 'object' && 'publicError' in error @@ -287,8 +329,7 @@ export class CodingConversationService { runtimeError(error); } })(); - this.acceptances.set(key, { fingerprint, flight }); - this.trimAcceptances(); + this.acceptances.set(key, { fingerprint, flight, state: acceptanceState }); return await flight; } @@ -298,20 +339,23 @@ export class CodingConversationService { } async setModel(conversationId: string, model: ProductModelRef): Promise { - await this.ensurePrepared(conversationId); const { project } = await this.projects.findActiveConversation(conversationId); + let selected: ProductModelRef; try { - const state = await this.runtime.setModel({ - conversationId, - accountId: model.accountId, - modelId: model.modelId, - }); - const withThinking = state.model - ? { model: { ...state.model, thinkingLevel: model.thinkingLevel }, modelResolution: 'resolved' as const } - : state; - if (withThinking.model) await this.runtime.setThinking({ conversationId, thinkingLevel: model.thinkingLevel }); - await this.projects.conversationStore(project.path).setModelState(conversationId, withThinking); - return withThinking; + selected = await this.runtime.validateModel(model); + } catch (error) { runtimeError(error); } + const state: ConversationModelState = { + model: selected, + modelResolution: 'resolved', + }; + await persist(() => this.projects.conversationStore(project.path).setModelState(conversationId, state)); + const preparing = this.prepareFlights.get(conversationId); + if (preparing) await preparing.catch(() => undefined); + try { + await this.runtime.dispose(conversationId); + this.prepareFlights.delete(conversationId); + await this.ensurePrepared(conversationId); + return state; } catch (error) { runtimeError(error); } } @@ -338,12 +382,13 @@ export class CodingConversationService { async fork(sourceConversationId: string, sourceEntryId?: string): Promise { const prepared = await this.ensurePrepared(sourceConversationId); const source = await this.projects.findActiveConversation(sourceConversationId); - const created = await this.projects.conversationStore(source.project.path).create({ + const store = this.projects.conversationStore(source.project.path); + const created = await persist(() => store.create({ agentId: source.conversation.agentId, title: `${source.conversation.title} (fork)`, model: source.conversation.model, modelResolution: source.conversation.modelResolution, - }); + })); try { await this.runtime.fork({ sourceConversationId, @@ -360,7 +405,15 @@ export class CodingConversationService { }); return publicConversation(created); } catch (error) { - await this.projects.conversationStore(source.project.path).delete(created.id).catch(() => undefined); + try { + await this.runtime.dispose(created.id); + const latest = await store.get(created.id); + await this.archiveSession(source.project.id, latest?.sessionKey); + await persist(() => store.delete(created.id)); + } catch { + // Keep product metadata when cleanup cannot complete so the partial + // runtime/session remains owned and can be diagnosed or deleted later. + } runtimeError(error); } } @@ -493,11 +546,30 @@ export class CodingConversationService { return flight; } - private trimAcceptances(): void { - while (this.acceptances.size > MAX_ACCEPTANCES) { - const key = this.acceptances.keys().next().value as string | undefined; - if (!key) return; - this.acceptances.delete(key); + private reserveAcceptanceSlot(): void { + while (this.acceptances.size >= MAX_ACCEPTANCES) { + const settled = [...this.acceptances.entries()] + .find(([, record]) => record.state.value === 'settled'); + if (!settled) { + throw new CodingConversationServiceError( + 503, + 'CODING_REQUEST_CAPACITY_EXCEEDED', + 'The local request registry is full', + ); + } + this.acceptances.delete(settled[0]); } } + + private async archiveSession(projectId: string, sessionKey: string | undefined): Promise { + if (!sessionKey) return; + if (!this.options.archiveSession) { + throw new CodingConversationServiceError( + 500, + 'CODING_STORAGE_WRITE_FAILED', + 'Coding session archival is unavailable', + ); + } + await persist(() => this.options.archiveSession!({ projectId, sessionKey })); + } } diff --git a/electron/coding-runtime/in-memory-conversation-runtime.ts b/electron/coding-runtime/in-memory-conversation-runtime.ts index f84cc57..592282b 100644 --- a/electron/coding-runtime/in-memory-conversation-runtime.ts +++ b/electron/coding-runtime/in-memory-conversation-runtime.ts @@ -14,6 +14,7 @@ import type { ForkConversationInput, ForkResult, PrepareConversationInput, + ProductModelRef, PromptAcceptance, PromptConversationInput, QueueAcceptance, @@ -316,6 +317,17 @@ export class InMemoryConversationRuntime implements CodingConversationRuntime { }, current.runId); } + async validateModel(model: ProductModelRef): Promise { + if (!model.accountId.trim() || !model.modelId.trim()) { + throw new CodingRuntimeContractError( + 'CODING_MODEL_UNAVAILABLE', + 'The selected model is unavailable', + true, + ); + } + return clone(model); + } + async setModel(input: SetConversationModelInput): Promise { const snapshot = this.snapshot(input.conversationId); const thinkingLevel = snapshot.conversation.model.model?.thinkingLevel ?? 'off'; diff --git a/electron/coding-runtime/pi/resource-loader.ts b/electron/coding-runtime/pi/resource-loader.ts index 0cea4f7..348aa13 100644 --- a/electron/coding-runtime/pi/resource-loader.ts +++ b/electron/coding-runtime/pi/resource-loader.ts @@ -1,10 +1,11 @@ -import { mkdir, stat } from 'node:fs/promises'; +import { mkdir, rename, stat } from 'node:fs/promises'; import path from 'node:path'; import { BUNDLED_CODING_SKILL_IDS, type BundledCodingSkillId, } from '../../../shared/coding-skills'; import { atomicWriteJson, atomicWriteText } from '../../coding-projects/atomic-json'; +import { validateSessionKey } from '../../coding-projects/conversation-store'; import type { PiProviderSelection } from './provider-config'; import type { PiManagedInputRevision } from './managed-input-revision'; @@ -104,6 +105,31 @@ export async function ensurePiManagedPaths(userDataDir: string): Promise { + const projectId = managedSegment(input.projectId, 'Project id'); + const sessionKey = validateSessionKey(input.sessionKey); + const paths = getPiManagedPaths(input.userDataDir); + const source = path.join(paths.sessionsDir, projectId, `${sessionKey}.jsonl`); + const targetDirectory = path.join(paths.trashDir, projectId); + const target = path.join(targetDirectory, `${sessionKey}.jsonl`); + await mkdir(targetDirectory, { recursive: true }); + try { + await rename(source, target); + return target; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + const alreadyArchived = await stat(target).catch((targetError: NodeJS.ErrnoException) => { + if (targetError.code === 'ENOENT') return null; + throw targetError; + }); + return alreadyArchived?.isFile() ? target : null; + } +} + function normalizeSkillIds(skillIds: readonly string[]): BundledCodingSkillId[] { const result: BundledCodingSkillId[] = []; const seen = new Set(); diff --git a/electron/coding-runtime/pi/runtime.ts b/electron/coding-runtime/pi/runtime.ts index 5562de8..f672995 100644 --- a/electron/coding-runtime/pi/runtime.ts +++ b/electron/coding-runtime/pi/runtime.ts @@ -672,6 +672,15 @@ export class PiConversationRuntime implements CodingConversationRuntime { } } + async validateModel(model: ProductModelRef): Promise { + const selection = await this.resolveModel(model); + return { + accountId: selection.accountId, + modelId: selection.modelId, + thinkingLevel: model.thinkingLevel, + }; + } + async setModel(input: SetConversationModelInput): Promise { await this.waitForProjection(input.conversationId); const snapshot = this.snapshot(input.conversationId); @@ -957,19 +966,30 @@ export class PiConversationRuntime implements CodingConversationRuntime { if (!this.isAuthenticationError || !this.refreshCredential || !input.model.model) { return await this.pool.prepare(input); } - return await this.providerRefresh.withSingleAuthRecovery({ - accountId: input.model.model.accountId, - operation: async () => await this.pool.prepare(input), - isAuthenticationError: this.isAuthenticationError, - refreshCredential: async () => await this.refreshCredential!(input.model.model!.accountId), - reopenWorker: async () => { - if (!this.pool.getState(input.conversationId)) return; - const recovered = await this.pool.recover(input.conversationId); - if (this.states.has(input.conversationId)) { - await this.requestHydration(input.conversationId, recovered, false); - } - }, - }); + try { + return await this.providerRefresh.withSingleAuthRecovery({ + accountId: input.model.model.accountId, + operation: async () => await this.pool.prepare(input), + isAuthenticationError: this.isAuthenticationError, + refreshCredential: async () => await this.refreshCredential!(input.model.model!.accountId), + reopenWorker: async () => { + if (!this.pool.getState(input.conversationId)) return; + const recovered = await this.pool.recover(input.conversationId); + if (this.states.has(input.conversationId)) { + await this.requestHydration(input.conversationId, recovered, false); + } + }, + }); + } catch (error) { + if (this.isAuthenticationError(error)) { + throw new CodingRuntimeContractError( + 'CODING_PROVIDER_AUTH_REQUIRED', + 'Provider authentication failed after one recovery attempt', + true, + ); + } + throw error; + } } private async acceptPrompt( @@ -1008,13 +1028,20 @@ export class PiConversationRuntime implements CodingConversationRuntime { }, runId); } } catch (error) { + const failure = this.isAuthenticationError?.(error) + ? new CodingRuntimeContractError( + 'CODING_PROVIDER_AUTH_REQUIRED', + 'Provider authentication failed after one recovery attempt', + true, + ) + : error; this.pool.failTopLevel( conversationId, runId, - error instanceof Error ? error : new Error('Prompt acceptance failed'), + failure instanceof Error ? failure : new Error('Prompt acceptance failed'), ); - this.failRun(conversationId, runId, error); - throw error; + this.failRun(conversationId, runId, failure); + throw failure; } } diff --git a/tests/unit/coding-core-routes.test.ts b/tests/unit/coding-core-routes.test.ts index 4c8c306..f1e9185 100644 --- a/tests/unit/coding-core-routes.test.ts +++ b/tests/unit/coding-core-routes.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { createServer, type Server } from 'node:http'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -8,6 +8,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { HostApiContext } from '../../electron/api/context'; import { dispatchHostApiRequest } from '../../electron/api/host-api-dispatcher'; import { createCodingComposition } from '../../electron/api/coding-composition'; +import { isCodingProviderAuthenticationError } from '../../electron/api/coding-provider-auth'; import { handleCodingConversationRoutes } from '../../electron/api/routes/coding-conversations'; import type { AgentBrowserModule } from '../../electron/agent-browser'; import { @@ -27,6 +28,7 @@ import { createMemoryCodingProjectStorage, } from '../../electron/coding-projects/project-store'; import type { PromptConversationInput } from '../../electron/coding-runtime/contracts'; +import { archivePiConversationSession } from '../../electron/coding-runtime/pi/resource-loader'; const roots: string[] = []; const servers: Server[] = []; @@ -48,8 +50,9 @@ async function setup(runtime = new InMemoryConversationRuntime({ })) { const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-core-')); roots.push(root); + let projectSequence = 0; const store = createCodingProjectStore(createMemoryCodingProjectStorage(), { - createId: () => 'project-a', + createId: () => projectSequence++ === 0 ? 'project-a' : `project-extra-${projectSequence}`, now: () => '2026-08-23T00:00:00.000Z', }); await createLocalCodingProject({ @@ -69,7 +72,7 @@ async function setup(runtime = new InMemoryConversationRuntime({ }, { now: '2026-08-23T00:00:00.000Z' }); const projects = new CodingProjectService(store); const conversations = new CodingConversationService(projects, runtime); - return { root, projects, conversations, runtime }; + return { root, store, projects, conversations, runtime }; } function context(setupResult: Awaited>): HostApiContext { @@ -146,6 +149,100 @@ describe('PI-100 coding core Host contract', () => { expect(result.conversations.getDiagnostics().workers).toEqual([]); }); + it('selects an unresolved model before preparing the Conversation', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-unresolved-')); + roots.push(root); + const store = createCodingProjectStore(createMemoryCodingProjectStorage(), { + createId: () => 'project-unresolved', + }); + await createLocalCodingProject({ projectPath: root }, store); + await createCodingProjectAgent(root, { + id: 'builder', + avatarId: 'avatar-01', + roleName: '实现者', + name: 'Builder', + model: null, + modelResolution: 'required', + responsibility: { + mission: 'Implement', owns: [], boundaries: [], collaborators: [], principles: [], + }, + }); + const runtime = new InMemoryConversationRuntime(); + const projects = new CodingProjectService(store); + const conversations = new CodingConversationService(projects, runtime); + const conversation = await conversations.createConversation({ agentId: 'builder', title: 'Resolve me' }); + const prepare = vi.spyOn(runtime, 'prepare'); + + await expect(conversations.setModel(conversation.id, MODEL)).resolves.toEqual({ + model: MODEL, + modelResolution: 'resolved', + }); + expect(prepare).toHaveBeenCalledWith(expect.objectContaining({ + conversationId: conversation.id, + model: { model: MODEL, modelResolution: 'resolved' }, + })); + await expect(projects.conversationStore(root).get(conversation.id)).resolves.toMatchObject({ + model: MODEL, + modelResolution: 'resolved', + }); + }); + + it('disposes and moves a bound session to Main-owned trash before deleting metadata', async () => { + const result = await setup(); + const conversation = await createConversation(result.conversations); + const sessionKey = 'session-delete'; + await result.projects.conversationStore(result.root).ensureSessionBinding(conversation.id, async () => ({ + piSessionId: 'pi-session-delete', + sessionKey, + })); + const userDataDir = await mkdtemp(path.join(tmpdir(), 'makelore-pi-trash-')); + roots.push(userDataDir); + const sourceDirectory = path.join( + userDataDir, + 'coding-runtime', + 'pi', + 'sessions', + 'project-a', + ); + await mkdir(sourceDirectory, { recursive: true }); + await writeFile(path.join(sourceDirectory, `${sessionKey}.jsonl`), 'session-data'); + const conversations = new CodingConversationService(result.projects, result.runtime, { + archiveSession: async (input) => { + await archivePiConversationSession({ userDataDir, ...input }); + }, + }); + + await conversations.deleteConversation(conversation.id); + + await expect(result.projects.conversationStore(result.root).get(conversation.id)).resolves.toBeNull(); + await expect(readFile(path.join( + userDataDir, + 'coding-runtime', + 'pi', + 'trash', + 'project-a', + `${sessionKey}.jsonl`, + ), 'utf8')).resolves.toBe('session-data'); + }); + + it('preserves Conversation metadata when session archival fails', async () => { + const result = await setup(); + const conversation = await createConversation(result.conversations); + await result.projects.conversationStore(result.root).ensureSessionBinding(conversation.id, async () => ({ + piSessionId: 'pi-session-preserved', + sessionKey: 'session-preserved', + })); + const conversations = new CodingConversationService(result.projects, result.runtime, { + archiveSession: async () => { throw new Error(`disk path=${result.root}`); }, + }); + + await expect(conversations.deleteConversation(conversation.id)).rejects.toMatchObject({ + status: 500, + code: 'CODING_STORAGE_WRITE_FAILED', + }); + await expect(result.projects.conversationStore(result.root).get(conversation.id)).resolves.not.toBeNull(); + }); + it('returns 202 acceptance, deduplicates requests, and exposes only safe diagnostics', async () => { const result = await setup(); const conversation = await createConversation(result.conversations); @@ -338,6 +435,95 @@ describe('PI-100 coding core Host contract', () => { }); }); + it('cleans the prior project for open, create, and explicit activation transitions', async () => { + const projectRoots = await Promise.all(['a', 'b', 'c'].map(async (name) => { + const root = await mkdtemp(path.join(tmpdir(), `makelore-pi-transition-${name}-`)); + roots.push(root); + return root; + })); + let nextId = 0; + const store = createCodingProjectStore(createMemoryCodingProjectStorage(), { + createId: () => `project-${++nextId}`, + }); + const first = await createLocalCodingProject({ projectPath: projectRoots[0]! }, store); + const deactivated: string[] = []; + const projects = new CodingProjectService(store, { + onProjectDeactivated: async (project) => { deactivated.push(project.id); }, + }); + + const opened = await projects.openProject(projectRoots[1]!); + const created = await projects.createProject({ projectPath: projectRoots[2]! }); + await projects.setActiveProject(first.project.id); + + expect(deactivated).toEqual([first.project.id, opened.id, created.project.id]); + }); + + it('removes absolute project roots from every core project response', async () => { + const result = await setup(); + const routeContext = context(result); + const responses = [ + await dispatchHostApiRequest(routeContext, { path: '/api/coding/projects' }), + await dispatchHostApiRequest(routeContext, { path: '/api/coding/projects/active' }), + await dispatchHostApiRequest(routeContext, { + path: `/api/coding/projects/config?projectId=project-a`, + }), + await dispatchHostApiRequest(routeContext, { + path: '/api/coding/projects/open', + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ projectPath: result.root }), + }), + ]; + const createRoot = await mkdtemp(path.join(tmpdir(), 'makelore-pi-safe-project-')); + roots.push(createRoot); + responses.push(await dispatchHostApiRequest(routeContext, { + path: '/api/coding/projects/create', + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ projectPath: createRoot }), + })); + + for (const response of responses) { + expect(response.status).toBeLessThan(300); + expect(JSON.stringify(response.json)).not.toContain(result.root); + expect(JSON.stringify(response.json)).not.toContain(createRoot); + expect(JSON.stringify(response.json)).not.toContain('"path"'); + } + }); + + it('maps persistence failures to a stable fixed Host error', async () => { + const result = await setup(); + const failingProjects = new CodingProjectService(result.store, { + writeConfig: async () => { throw new Error(`secret disk path=${result.root}`); }, + }); + const failingConversations = new CodingConversationService(failingProjects, result.runtime); + const current = await failingProjects.getConfig('project-a'); + const response = await dispatchHostApiRequest(context({ + ...result, + projects: failingProjects, + conversations: failingConversations, + }), { + path: '/api/coding/projects/config', + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ projectId: 'project-a', config: current.config }), + }); + + expect(response).toMatchObject({ + status: 500, + json: { + code: 'CODING_STORAGE_WRITE_FAILED', + error: '本地数据写入失败,请检查存储后重试。', + }, + }); + expect(JSON.stringify(response.json)).not.toContain(result.root); + }); + + it('classifies production Provider authentication failures without exposing details', () => { + expect(isCodingProviderAuthenticationError(new Error('Pi RPC prompt failed: HTTP 401'))).toBe(true); + expect(isCodingProviderAuthenticationError(new Error('Pi RPC prompt failed: rate limited'))).toBe(false); + }); + it('redacts unknown runtime failures from Host responses', async () => { const result = await setup(); const conversation = await createConversation(result.conversations); @@ -350,7 +536,7 @@ describe('PI-100 coding core Host contract', () => { status: 503, json: { code: 'CODING_RUNTIME_UNAVAILABLE', - error: 'The local coding runtime is unavailable', + error: '本地编程运行时暂时不可用。', }, }); expect(JSON.stringify(response.json)).not.toContain('secret'); @@ -359,15 +545,24 @@ describe('PI-100 coding core Host contract', () => { it('retains uncertain acceptance and never resends the same request id', async () => { class UncertainRuntime extends InMemoryConversationRuntime { - calls = 0; + uncertainCalls = 0; - override async prompt(_input: PromptConversationInput): Promise { - this.calls += 1; - throw new CodingRuntimeContractError( - 'CODING_REQUEST_UNCERTAIN', - 'The local Agent did not confirm the request', - true, - ); + override async prompt(input: PromptConversationInput) { + if (input.clientRequestId === 'request-uncertain') { + this.uncertainCalls += 1; + throw new CodingRuntimeContractError( + 'CODING_REQUEST_UNCERTAIN', + 'The local Agent did not confirm the request', + true, + ); + } + return { + accepted: true as const, + conversationId: input.conversationId, + clientRequestId: input.clientRequestId, + runId: `run-${input.clientRequestId}`, + mode: input.mode, + }; } } const runtime = new UncertainRuntime(); @@ -383,9 +578,58 @@ describe('PI-100 coding core Host contract', () => { await expect(result.conversations.acceptPrompt(input)).rejects.toMatchObject({ code: 'CODING_REQUEST_UNCERTAIN', }); + for (let index = 0; index < 512; index += 1) { + await result.conversations.acceptPrompt({ + conversationId: conversation.id, + clientRequestId: `request-settled-${index}`, + mode: 'prompt', + text: 'Settled', + attachments: [], + }); + } await expect(result.conversations.acceptPrompt(input)).rejects.toMatchObject({ code: 'CODING_REQUEST_UNCERTAIN', }); - expect(runtime.calls).toBe(1); + expect(runtime.uncertainCalls).toBe(1); + }, 15_000); + + it('disposes and archives a partially created fork before metadata rollback', async () => { + let bindFork: ((conversationId: string) => Promise) | undefined; + let forkTargetId = ''; + class FailingForkRuntime extends InMemoryConversationRuntime { + override async fork(input: Parameters[0]): Promise { + forkTargetId = input.conversation.conversationId; + await bindFork?.(forkTargetId); + throw new CodingRuntimeContractError( + 'CODING_SESSION_UNREADABLE', + 'Fork hydration failed', + true, + ); + } + } + const runtime = new FailingForkRuntime(); + const result = await setup(runtime); + const store = result.projects.conversationStore(result.root); + bindFork = async (conversationId) => { + await store.ensureSessionBinding(conversationId, async () => ({ + piSessionId: 'fork-session', + sessionKey: 'fork-session-key', + })); + }; + const archiveSession = vi.fn(async () => undefined); + const conversations = new CodingConversationService(result.projects, runtime, { archiveSession }); + const source = await createConversation(conversations); + await conversations.getSnapshot(source.id); + const dispose = vi.spyOn(runtime, 'dispose'); + + await expect(conversations.fork(source.id)).rejects.toMatchObject({ + code: 'CODING_SESSION_UNREADABLE', + }); + expect(dispose).toHaveBeenCalledWith(forkTargetId); + expect(archiveSession).toHaveBeenCalledWith({ + projectId: 'project-a', + sessionKey: 'fork-session-key', + }); + await expect(store.get(forkTargetId)).resolves.toBeNull(); }); }); diff --git a/tests/unit/coding-files-routes.test.ts b/tests/unit/coding-files-routes.test.ts index 6b9168b..de83539 100644 --- a/tests/unit/coding-files-routes.test.ts +++ b/tests/unit/coding-files-routes.test.ts @@ -85,7 +85,7 @@ describe('PI-105 coding product routes', () => { }); expect(response).toMatchObject({ status: 404, - json: { success: false, code: 'CODING_FILE_NOT_FOUND', error: 'Project file does not exist' }, + json: { success: false, code: 'CODING_FILE_NOT_FOUND', error: '指定的项目文件不存在。' }, }); expect(JSON.stringify(response.json)).not.toContain(absolute); }); diff --git a/tests/unit/pi-runtime-auth-recovery.test.ts b/tests/unit/pi-runtime-auth-recovery.test.ts index 578a45b..3c6b6d9 100644 --- a/tests/unit/pi-runtime-auth-recovery.test.ts +++ b/tests/unit/pi-runtime-auth-recovery.test.ts @@ -133,7 +133,10 @@ describe('Pi runtime Provider authentication recovery', () => { text: 'Do not leak credentials', attachments: [], })).rejects.toMatchObject({ - code: 'PI_RPC_RESPONSE_ERROR', + publicError: { + code: 'CODING_PROVIDER_AUTH_REQUIRED', + recoverable: true, + }, }); await expect.poll(() => workers.length).toBe(2);