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 new file mode 100644 index 0000000..5acc248 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260823-pi-core-host-api-a17f6c2e.md @@ -0,0 +1,161 @@ +# Task: PI-100 Core Host API and Runtime Composition + +## Identity + +- Task ID: 20260823-pi-core-host-api-a17f6c2e +- Mode: Feature +- Branch: codex/20260823-pi-core-host-api-a17f6c2e-pi-core-host-api +- Worktree: D:\Datas\OthersProjects\makelore-pi-core-host-api-a17f6c2e +- Base commit: 98bac206396198cc276c659ed988372fc5c8bc10 +- Owner: codex-root +- Status: Review + +## Scope + +- Implement PI-100's vendor-neutral project and Conversation service, core + `/api/coding` routes, runtime Snapshot/Patch SSE, interactions, diagnostics, + and HTTP 202 prompt acceptance contract. +- Compose the production Pi runtime behind one Main-owned product Host + composition, reusing PI-105's attachment store, product tools, change tracker, + file routes, skills, and command projection. +- Replace PI-105's temporary active-project adapter with the vendor-neutral + project store and inject the target worker's live `get_commands` result. +- Add focused route/service/composition tests and run the repository-required + verification for Main/Renderer/packaging-impacting changes. + +## Intent And Constraints + +- Base is the reviewed cumulative PI-105 HEAD + `98bac206396198cc276c659ed988372fc5c8bc10`; PI-100 is the planner's exact + sole Ready Frontier. +- Project/partner/Conversation metadata creation must remain local-only and + must not spawn Pi. Runtime preparation stays lazy at Snapshot or execution + boundaries. +- Routes validate active-project ownership and return stable, sanitized product + errors. No Pi wire fields, absolute session paths, credentials, provider + bodies, stderr, stacks, or extension paths may cross to Renderer. +- Accepted prompt/steer/follow-up mutations return HTTP 202 without waiting for + provider first token or settle. Accepted `clientRequestId` values are bounded + and deduplicated; uncertain mutations are never silently resent. +- The service is the deep product seam. Route files own HTTP parsing/projection, + while process/RPC/event/session/provider lifecycle stays inside the existing + Pi runtime modules. +- Do not introduce an OpenCode fallback, runtime selector, compatibility + adapter, second product-tool composition, or speculative security framework. +- Real external Provider verification remains Explicitly Waived / Accepted + Risk with `realTurnVerified=false`; macOS x64/arm64 remains deferred to the + mandatory PI-150 gate. Neither is Pass evidence. +- No subagents are authorized for this repository task. + +## Project Context Loaded + +- `maintain-project-docs` Concurrent Task Gate passed in the isolated worktree + above; the canonical `main` worktree is occupied/dirty and was not modified. +- Planning Gate inputs, canonical integrated memory, architecture/domain files, + the only semantically adjacent active task, PI-105 completion record, and the + planner's latest PI-100 ticket/Spec sections were read before planning. +- The adjacent planning task `20260820-partner-create-model-parity-c83d` has no + defined scope or claimed files, so no ownership conflict is present. +- Confirmed implementation seams: schema-v2 project/conversation stores, + `CodingConversationRuntime`, `PiConversationRuntime`, managed extension host, + PI-105's `CodingProductComposition`, and the shared Host dispatcher already + exist. The temporary OpenCode project-store callback in Main is the seam PI-100 + must remove from the product composition. +- The canonical project-positioning and project-level success fields remain + placeholders; ticket boundaries and product constraints therefore come from + repository guidance, current-state memory, and the planner-owned normative + Pi documents. +- Gate result: Passed. + +## Plan + +1. Deepen the vendor-neutral project/Conversation service and runtime contract + with the minimal Host-facing operations, bounded acceptance registry, event + session, interaction correlation, command lookup, and safe diagnostics. +2. Implement the project and Conversation route modules, including metadata + CRUD, Snapshot, 202 mutations, SSE, interactions, and diagnostics with + ownership validation and stable error projection. +3. Build one production Main composition from the vendor-neutral project store, + existing PI runtime/provider/resource/extension/product-tool seams, then + register it in the shared Host dispatcher and shutdown lifecycle. +4. Add focused service/route/composition regressions for local-only creation, + dedupe/uncertain behavior, snapshot-first SSE, redaction, interaction IDs, + project adapter replacement, and live command projection. +5. Run focused tests, all Pi/coding tests, typecheck, lint, full unit suite, + Vite build, Windows Electron coverage, documentation gates, and planner + review; correct confirmed failures before completion. + +## Outcome + +- Added the vendor-neutral `CodingProjectService` and + `CodingConversationService`. Project/Agent/Conversation metadata creation is + local-only; Snapshot and execution operations prepare the target worker + lazily. +- Implemented the Spec 12.1 core project/Conversation routes, global and + target-filtered snapshot-first SSE, 202 prompt/steer/follow-up acceptance, + bounded concurrent dedupe, uncertain-request retention, interaction + correlation, and privacy-safe runtime diagnostics. +- Extended the runtime product contract with safe commands, interactions, + provider/resource invalidation, and diagnostics. Pi commands are fetched from + the live target worker with `get_commands`; absent/unready workers degrade to + the managed Makelore/skill catalog without exposing raw Pi fields. +- Replaced PI-105's temporary OpenCode active-project callback with the product + project service. Main now builds one shared product/Pi composition containing + attachments, product tools, extension host, worker pool, subagents, + project/Conversation services, and Host routes; the composition owns shutdown. +- Provider changes advance the Pi provider revision, project config/knowledge + changes advance the resource revision and invalidate cached Agent resources, + and deactivating a project disposes its Conversation workers/interactions. +- No Renderer route was migrated here; PI-110 remains the next consumer of the + new Main contract. Old `/api/opencode` removal remains PI-140 scope. + +## Verification + +- `pnpm install --frozen-lockfile` — Passed using the repository-pinned pnpm. +- `pnpm run typecheck` — Passed after final implementation changes. +- `pnpm run lint:check` — Passed with zero errors; six existing Renderer + warnings remain outside PI-100-owned files. +- Focused PI/coding/provider suites — Passed: 7 files / 53 tests. +- In-memory Conversation contract plus PI-100 Host/SSE suite — Passed: 2 files / + 23 tests. Coverage includes local-only create, one Main composition, concurrent + dedupe, uncertain no-resend, global and target snapshot-first streams, real + HTTP SSE ordering, interaction route ID correlation, command degradation, and + runtime error redaction. +- `pnpm test` — Passed: 208 files / 2247 tests passed, 2 skipped. +- `pnpm run build:vite` — Passed for Renderer, Electron Main, Preload, and + utility worker. Existing dynamic-import/chunk-size warnings remain. +- `pnpm run test:e2e` — Production build passed and 27/28 Windows Electron tests + passed. The pre-existing OpenCode slash-command assertion failed because its + legacy `/command` request omitted `model`; isolated rerun reproduced it. This + task changes no Renderer file, OpenCode command route, or slash-command spec, + and the new composition performs no runtime/provider/project read before a + coding Snapshot/execution request, so no causal PI-100 path was found. +- Real external Provider turns remain **Explicitly Waived / Accepted Risk**; + `realTurnVerified=false`. This is not Pass evidence. +- macOS x64/arm64 validation remains skipped by user direction and mandatory at + PI-150. This is not Pass evidence. + +## Follow-ups + +- PI-110 should bind the Renderer typed facade/store to these `/api/coding` + routes and implement target Snapshot recovery on seq gaps. +- PI-140 should remove the legacy OpenCode Renderer/routes/runtime residue after + the hard cutover; PI-150 still owns final packaged platform qualification. +- The legacy OpenCode slash-command E2E missing-model assertion should be + reconciled by its owning UI/runtime task; it is outside PI-100 scope. + +## Promotion Candidates + +- Target canonical documents: `current-state.md`, system overview, module map, + and PI ticket/DAG status. +- Proposal: record PI-100 as implemented with the product project/Conversation + services, core Host API, snapshot-first global SSE, one Main Pi composition, + provider/resource revision integration, and PI-105 project/live-command seams + closed. +- Evidence: cumulative task commit(s), the focused Host/SSE tests, full unit + result, typecheck/lint, and Vite build listed above. +- Future impact: PI-110 may consume the new Main contract; PI-140 must still own + legacy removal. The real Provider waiver and macOS deferral remain unchanged. +- Semantic conflicts: none identified with accepted architecture or adjacent + active task scopes. Human confirmation is not required for promotion, but the + planner must review the cumulative HEAD before changing the Ready Frontier. diff --git a/electron/api/coding-composition.ts b/electron/api/coding-composition.ts new file mode 100644 index 0000000..4457315 --- /dev/null +++ b/electron/api/coding-composition.ts @@ -0,0 +1,176 @@ +import path from 'node:path'; +import type { AgentBrowserModule } from '../agent-browser'; +import { CodingAttachmentStore } from '../coding-projects/attachment-store'; +import { createCodingConversationStore } from '../coding-projects/conversation-store'; +import { CodingProjectService } from '../coding-projects/project-service'; +import { + createCodingProjectStore, + type CodingProjectStorage, +} from '../coding-projects/project-store'; +import { CodingConversationService } from '../coding-runtime/conversation-service'; +import { PiManagedExtensionHost } from '../coding-runtime/pi/extension-host'; +import { PiManagedInputRevisionCoordinator } from '../coding-runtime/pi/managed-input-revision'; +import { PiProductTools } from '../coding-runtime/pi/product-tools'; +import { + buildPiProviderCatalog, + resolvePiProviderCredentialFromSecretStore, + selectPiProviderModel, +} from '../coding-runtime/pi/provider-config'; +import { + createPiManagedWorkerOpener, + PiConversationRuntime, +} from '../coding-runtime/pi/runtime'; +import { PiSessionRegistry } from '../coding-runtime/pi/session-registry'; +import { createPiManagedSubagentChildOpener } from '../coding-runtime/pi/subagent-child'; +import { PiSubagentScheduler } from '../coding-runtime/pi/subagent'; +import { PiProcessBudget, PiWorkerPool } from '../coding-runtime/pi/worker-pool'; +import { getProviderService } from '../services/providers/provider-service'; +import { createCodingProductHost, type CodingProductComposition } from './coding-product-services'; + +export interface CodingCompositionPaths { + executablePath: string; + cliPath: string; + userDataDir: string; + bundledSkillsDir: string; +} + +export interface CreateCodingCompositionOptions { + storage: CodingProjectStorage; + browser: AgentBrowserModule; + paths: CodingCompositionPaths; + localProxyCredential?: string; +} + +export function resolveCodingPiRuntimePaths(input: { + isPackaged: boolean; + resourcesPath: string; + appPath: string; + executablePath: string; +}): Pick { + return { + executablePath: input.executablePath, + cliPath: input.isPackaged + ? path.join(input.resourcesPath, 'pi-runtime', 'dist', 'cli.js') + : path.join( + input.appPath, + 'node_modules', + '@earendil-works', + 'pi-coding-agent', + 'dist', + 'cli.js', + ), + }; +} + +export function createCodingComposition( + options: CreateCodingCompositionOptions, +): CodingProductComposition { + const projectStore = createCodingProjectStore(options.storage); + const attachments = new CodingAttachmentStore( + path.join(options.paths.userDataDir, 'coding-runtime', 'attachments'), + ); + const productTools = new PiProductTools({ + browser: options.browser, + attachments, + bundledSkillsDir: options.paths.bundledSkillsDir, + }); + const extensionHost = new PiManagedExtensionHost(); + extensionHost.configureProductTools(productTools); + const registry = new PiSessionRegistry({ projectStore }); + const revisions = new PiManagedInputRevisionCoordinator(); + const processBudget = new PiProcessBudget(); + const loadProviderInput = async () => ({ + accounts: await getProviderService().listAccounts(), + modelSummaries: [], + }); + const workerPool = new PiWorkerPool({ + processBudget, + revisionCoordinator: revisions, + openWorker: createPiManagedWorkerOpener({ + registry, + executablePath: options.paths.executablePath, + cliPath: options.paths.cliPath, + userDataDir: options.paths.userDataDir, + bundledSkillsDir: options.paths.bundledSkillsDir, + loadProviderInput, + resolveCredential: resolvePiProviderCredentialFromSecretStore, + ...(options.localProxyCredential + ? { getLocalProxyCredential: async () => options.localProxyCredential } + : {}), + extensionHost, + }), + }); + const childOpener = createPiManagedSubagentChildOpener({ + projectStore, + executablePath: options.paths.executablePath, + cliPath: options.paths.cliPath, + userDataDir: options.paths.userDataDir, + bundledSkillsDir: options.paths.bundledSkillsDir, + extensionHost, + loadProviderInput, + resolveCredential: resolvePiProviderCredentialFromSecretStore, + getRevision: () => revisions.current, + ...(options.localProxyCredential + ? { getLocalProxyCredential: async () => options.localProxyCredential } + : {}), + }); + const subagents = new PiSubagentScheduler({ + openChild: childOpener, + processBudget, + reclaimProcessCapacity: (signal) => workerPool.reclaimIdleWorker(signal), + }); + const runtime = new PiConversationRuntime({ + pool: workerPool, + registry, + extensionHost, + subagentScheduler: subagents, + resolveModel: async (model) => selectPiProviderModel( + buildPiProviderCatalog(await loadProviderInput()), + model, + ), + resolveImages: async (refs) => await Promise.all(refs.map(async ({ attachmentId }) => { + const record = await attachments.read(attachmentId); + return { + type: 'image', + data: record.data.toString('base64'), + mimeType: record.mime, + }; + })), + }); + const projects = new CodingProjectService(projectStore, { + onResourcesChanged: async (project) => { + runtime.markResourcesStale(); + const conversations = await createCodingConversationStore(project.path).read() + .then((file) => file.conversations) + .catch(() => []); + for (const conversation of conversations) registry.forget(conversation.id); + }, + onProjectDeactivated: async (project) => { + const conversations = await createCodingConversationStore(project.path).read() + .then((file) => file.conversations) + .catch(() => []); + await Promise.allSettled([ + options.browser.close(project.path), + ...conversations.map(({ id }) => runtime.dispose(id)), + ]); + }, + }); + const conversations = new CodingConversationService(projects, runtime); + const host = createCodingProductHost({ + projects, + productTools, + listPiCommands: (conversationId) => conversations.listLiveCommands(conversationId), + }); + return { + attachments, + productTools, + projects, + conversations, + runtime, + host, + async shutdown() { + await subagents.close(); + await runtime.shutdown(); + }, + }; +} diff --git a/electron/api/coding-product-services.ts b/electron/api/coding-product-services.ts index 6f779a5..d0b0b81 100644 --- a/electron/api/coding-product-services.ts +++ b/electron/api/coding-product-services.ts @@ -7,10 +7,15 @@ import type { ProductCodingSkill, ProductPiCommandInput, } from '../../shared/coding-product-tools'; -import { createCodingConversationStore } from '../coding-projects/conversation-store'; import type { CodingAttachmentStore } from '../coding-projects/attachment-store'; import { readCodingProjectConfigV2 } from '../coding-projects/project-config'; import { CodingProjectFileService } from '../coding-projects/project-files'; +import { + CodingProjectServiceError, + type CodingProjectService, +} from '../coding-projects/project-service'; +import type { CodingConversationService } from '../coding-runtime/conversation-service'; +import type { CodingConversationRuntime } from '../coding-runtime/contracts'; import type { PiProductTools } from '../coding-runtime/pi/product-tools'; export interface ActiveCodingProject { @@ -31,7 +36,11 @@ export interface CodingProductHost { export interface CodingProductComposition { attachments: CodingAttachmentStore; productTools: PiProductTools; + projects: CodingProjectService; + conversations: CodingConversationService; + runtime: CodingConversationRuntime; host: CodingProductHost; + shutdown(): Promise; } export class CodingProductHostError extends Error { @@ -45,7 +54,7 @@ export class CodingProductHostError extends Error { } export interface CodingProductHostOptions { - getActiveProject(): Promise; + projects: Pick; productTools: PiProductTools; files?: CodingProjectFileService; listPiCommands?(conversationId: string): Promise; @@ -77,7 +86,7 @@ export function createCodingProductHost(options: CodingProductHostOptions): Codi const files = options.files ?? new CodingProjectFileService(); async function activeProject(): Promise { - const project = await options.getActiveProject(); + const project = await options.projects.getActiveProject(); if (!project) { throw new CodingProductHostError( 409, @@ -118,15 +127,17 @@ export function createCodingProductHost(options: CodingProductHostOptions): Codi project: ActiveCodingProject; skillIds: readonly string[]; }> { - const project = await activeProject(); - const conversation = await createCodingConversationStore(project.path).get(conversationId); - if (!conversation) { - throw new CodingProductHostError( - 404, - 'CODING_CONVERSATION_NOT_FOUND', - 'Coding Conversation does not exist', - ); + let context; + try { + context = await options.projects.findActiveConversation(conversationId); + } catch (error) { + if (error instanceof CodingProjectServiceError + && (error.status === 404 || error.status === 409)) { + throw new CodingProductHostError(error.status, error.code, error.message); + } + throw error; } + const { project, conversation } = context; return { project, skillIds: await selectedSkillIds(project.path, conversation.agentId), diff --git a/electron/api/host-api-transport.ts b/electron/api/host-api-transport.ts index 4c2823a..34151fc 100644 --- a/electron/api/host-api-transport.ts +++ b/electron/api/host-api-transport.ts @@ -6,7 +6,9 @@ export function shouldUseLoopbackHostApi(path: string, method = 'GET'): boolean { const normalizedMethod = method.toUpperCase(); const pathname = path.split('?', 1)[0] || path; - if (pathname === '/api/events' || pathname === '/api/opencode/events') return true; + if (pathname === '/api/events' + || pathname === '/api/opencode/events' + || pathname === '/api/coding/events') return true; if (pathname.startsWith('/api/ai-proxy/')) return true; if (pathname.includes('/events') && pathname.startsWith('/api/image-workspace/')) return true; if ( diff --git a/electron/api/route-handlers.ts b/electron/api/route-handlers.ts index b2f66d9..6fdf374 100644 --- a/electron/api/route-handlers.ts +++ b/electron/api/route-handlers.ts @@ -18,6 +18,8 @@ import { handleFileRoutes } from './routes/files'; import { handleMeowaGameAssetsRoutes } from './routes/meowa-game-assets'; import { handleAgentBrowserRoutes } from './routes/agent-browser'; import { handleCodingFileRoutes } from './routes/coding-files'; +import { handleCodingProjectRoutes } from './routes/coding-projects'; +import { handleCodingConversationRoutes } from './routes/coding-conversations'; export type HostApiRouteHandler = ( req: IncomingMessage, @@ -43,6 +45,8 @@ export const hostApiRouteHandlers: readonly HostApiRouteHandler[] = [ handleWorksRoutes, handleAgentBrowserRoutes, handleUserSyncRoutes, + handleCodingProjectRoutes, + handleCodingConversationRoutes, handleCodingFileRoutes, handleOpencodeRoutes, handleSettingsRoutes, diff --git a/electron/api/routes/coding-conversations.ts b/electron/api/routes/coding-conversations.ts new file mode 100644 index 0000000..24547c4 --- /dev/null +++ b/electron/api/routes/coding-conversations.ts @@ -0,0 +1,218 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { ConversationInteractionResponse } from '../../coding-runtime/contracts'; +import { CodingConversationServiceError } from '../../coding-runtime/conversation-service'; +import { normalizeProductModelRef } from '../../coding-projects/project-config'; +import type { HostApiContext } from '../context'; +import { + flushStreamingHeaders, + parseJsonBody, + sendJson, + sendNoContent, + writeStreamingChunk, +} from '../route-utils'; +import { decodeRouteId, sendCodingRouteError } from './coding-route-errors'; + +const THINKING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high']); + +function invalidRequest(message: string): never { + throw new CodingConversationServiceError(400, 'CODING_CONVERSATION_REQUEST_INVALID', message); +} + +function isConversationRoute(pathname: string, method: string | undefined): boolean { + if ((pathname === '/api/coding/events' + || pathname === '/api/coding/interactions' + || pathname === '/api/coding/runtime/diagnostics') && method === 'GET') return true; + if (/^\/api\/coding\/interactions\/[^/]+\/respond$/.test(pathname)) return method === 'POST'; + const match = pathname.match( + /^\/api\/coding\/conversations\/[^/]+(?:\/(snapshot|prompt|abort|model|thinking|compact|fork|recover))?$/, + ); + if (!match) return false; + if (!match[1]) return method === 'GET' || method === 'PATCH' || method === 'DELETE'; + if (match[1] === 'snapshot') return method === 'GET'; + return method === 'POST'; +} + +async function sendEvent( + res: ServerResponse, + event: string, + data: unknown, + id?: string, +): Promise { + return await writeStreamingChunk( + res, + `${id ? `id: ${id}\n` : ''}event: ${event}\ndata: ${JSON.stringify(data)}\n\n`, + ); +} + +export async function handleCodingConversationRoutes( + req: IncomingMessage, + res: ServerResponse, + url: URL, + ctx: HostApiContext, +): Promise { + if (!isConversationRoute(url.pathname, req.method)) return false; + const service = ctx.codingProducts?.conversations; + if (!service) { + sendJson(res, 503, { + success: false, + code: 'CODING_CORE_UNAVAILABLE', + error: 'Coding services are unavailable', + }); + return true; + } + + try { + if (url.pathname === '/api/coding/events' && req.method === 'GET') { + const conversationId = url.searchParams.get('conversationId')?.trim() || undefined; + const stream = await service.openEventStream(conversationId); + res.statusCode = 200; + res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); + res.setHeader('Cache-Control', 'no-cache, no-transform'); + res.setHeader('Connection', 'keep-alive'); + flushStreamingHeaders(res); + const close = () => stream.close(); + req.once('close', close); + res.once('close', close); + try { + for (const snapshot of stream.snapshots) { + if (!await sendEvent(res, 'snapshot', { + type: 'snapshot', + conversationId: snapshot.conversation.id, + workerGeneration: snapshot.cursor.workerGeneration, + seq: snapshot.cursor.seq, + snapshot, + }, `${snapshot.conversation.id}:${snapshot.cursor.workerGeneration}:${snapshot.cursor.seq}`)) return true; + } + for await (const event of stream.events) { + if (!await sendEvent( + res, + event.type, + event, + `${event.conversationId}:${event.workerGeneration}:${event.seq}`, + )) break; + } + } finally { + req.off('close', close); + res.off('close', close); + stream.close(); + if (!res.writableEnded) res.end(); + } + return true; + } + if (url.pathname === '/api/coding/interactions' && req.method === 'GET') { + sendJson(res, 200, { + interactions: await service.listInteractions( + url.searchParams.get('conversationId')?.trim() || undefined, + ), + }); + return true; + } + const interactionMatch = url.pathname.match(/^\/api\/coding\/interactions\/([^/]+)\/respond$/); + if (interactionMatch && req.method === 'POST') { + const interactionId = decodeRouteId(interactionMatch[1]); + const body = await parseJsonBody<{ + conversationId?: string; + cancelled?: unknown; + optionId?: unknown; + confirmed?: unknown; + value?: unknown; + }>(req); + const conversationId = typeof body.conversationId === 'string' ? body.conversationId.trim() : ''; + let response: ConversationInteractionResponse; + if (body.cancelled === true) response = { interactionId, cancelled: true }; + else if (typeof body.optionId === 'string') response = { interactionId, optionId: body.optionId }; + else if (typeof body.confirmed === 'boolean') response = { interactionId, confirmed: body.confirmed }; + else if (typeof body.value === 'string') response = { interactionId, value: body.value }; + else invalidRequest('Interaction response is invalid'); + await service.respondInteraction(conversationId, response); + sendNoContent(res); + return true; + } + if (url.pathname === '/api/coding/runtime/diagnostics' && req.method === 'GET') { + sendJson(res, 200, { runtime: service.getDiagnostics() }); + return true; + } + + const match = url.pathname.match(/^\/api\/coding\/conversations\/([^/]+)(?:\/(snapshot|prompt|abort|model|thinking|compact|fork|recover))?$/); + if (!match) return false; + const conversationId = decodeRouteId(match[1]); + const action = match[2]; + if (!action && req.method === 'GET') { + sendJson(res, 200, { conversation: await service.getConversation(conversationId) }); + return true; + } + if (!action && req.method === 'PATCH') { + sendJson(res, 200, { + conversation: await service.patchConversation( + conversationId, + await parseJsonBody(req), + ), + }); + return true; + } + if (!action && req.method === 'DELETE') { + await service.deleteConversation(conversationId); + sendNoContent(res); + return true; + } + if (action === 'snapshot' && req.method === 'GET') { + sendJson(res, 200, { snapshot: await service.getSnapshot(conversationId) }); + return true; + } + if (action === 'prompt' && req.method === 'POST') { + const body = await parseJsonBody<{ + clientRequestId?: unknown; + mode?: unknown; + text?: unknown; + attachments?: unknown; + }>(req); + sendJson(res, 202, { acceptance: await service.acceptPrompt({ conversationId, ...body }) }); + return true; + } + if (action === 'abort' && req.method === 'POST') { + await service.abort(conversationId); + sendNoContent(res); + return true; + } + if (action === 'model' && req.method === 'POST') { + const body = await parseJsonBody<{ model?: unknown }>(req); + let model; + try { model = normalizeProductModelRef(body.model); } catch { invalidRequest('Product model is invalid'); } + sendJson(res, 200, { model: await service.setModel(conversationId, model) }); + return true; + } + if (action === 'thinking' && req.method === 'POST') { + const body = await parseJsonBody<{ thinkingLevel?: unknown }>(req); + if (!THINKING_LEVELS.has(String(body.thinkingLevel))) invalidRequest('Thinking level is invalid'); + sendJson(res, 200, { + model: await service.setThinking( + conversationId, + body.thinkingLevel as 'off' | 'minimal' | 'low' | 'medium' | 'high', + ), + }); + return true; + } + if (action === 'compact' && req.method === 'POST') { + await service.compact(conversationId); + sendNoContent(res); + return true; + } + if (action === 'recover' && req.method === 'POST') { + await service.recover(conversationId); + sendNoContent(res); + return true; + } + if (action === 'fork' && req.method === 'POST') { + const body = await parseJsonBody<{ sourceEntryId?: unknown }>(req); + const sourceEntryId = typeof body.sourceEntryId === 'string' && body.sourceEntryId.trim() + ? body.sourceEntryId.trim() + : undefined; + sendJson(res, 201, { conversation: await service.fork(conversationId, sourceEntryId) }); + return true; + } + } catch (error) { + sendCodingRouteError(res, error); + return true; + } + return false; +} diff --git a/electron/api/routes/coding-projects.ts b/electron/api/routes/coding-projects.ts new file mode 100644 index 0000000..9b05192 --- /dev/null +++ b/electron/api/routes/coding-projects.ts @@ -0,0 +1,115 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { ProjectType } from '../../../shared/project-config'; +import type { HostApiContext } from '../context'; +import { parseJsonBody, sendJson, sendNoContent } from '../route-utils'; +import { sendCodingRouteError } from './coding-route-errors'; + +function isProjectRoute(pathname: string): boolean { + return pathname === '/api/coding/projects' + || pathname.startsWith('/api/coding/projects/'); +} + +export async function handleCodingProjectRoutes( + req: IncomingMessage, + res: ServerResponse, + url: URL, + ctx: HostApiContext, +): Promise { + if (!isProjectRoute(url.pathname)) return false; + const projects = ctx.codingProducts?.projects; + const conversations = ctx.codingProducts?.conversations; + if (!projects || !conversations) { + sendJson(res, 503, { + success: false, + code: 'CODING_CORE_UNAVAILABLE', + error: 'Coding services are unavailable', + }); + return true; + } + + try { + if (url.pathname === '/api/coding/projects' && req.method === 'GET') { + const [items, activeProject] = await Promise.all([ + projects.listProjects(), + projects.getActiveProject(), + ]); + sendJson(res, 200, { projects: items, 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 ?? '') }); + return true; + } + if (url.pathname === '/api/coding/projects/create' && req.method === 'POST') { + const body = await parseJsonBody<{ + projectPath?: string; + parentPath?: string; + projectName?: string; + projectType?: ProjectType; + }>(req); + sendJson(res, 201, { snapshot: await projects.createProject(body) }); + return true; + } + if (url.pathname === '/api/coding/projects/remove' && req.method === 'POST') { + const body = await parseJsonBody<{ projectId?: string }>(req); + await projects.removeProject(body.projectId ?? ''); + sendNoContent(res); + return true; + } + if (url.pathname === '/api/coding/projects/active' && req.method === 'GET') { + sendJson(res, 200, { project: 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 ?? '') }); + 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), + }); + 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), + }); + return true; + } + if (url.pathname === '/api/coding/projects/knowledge' && req.method === 'POST') { + const body = await parseJsonBody<{ + projectId?: string; + fileName?: string; + contentBase64?: string; + }>(req); + sendJson(res, 201, { + knowledgeFiles: await projects.addKnowledgeFile({ + projectId: body.projectId ?? '', + fileName: body.fileName ?? '', + contentBase64: body.contentBase64 ?? '', + }), + }); + return true; + } + if (url.pathname === '/api/coding/projects/conversations' && req.method === 'GET') { + sendJson(res, 200, { + conversations: await conversations.listConversations( + url.searchParams.get('projectId')?.trim() || undefined, + ), + }); + return true; + } + if (url.pathname === '/api/coding/projects/conversations' && req.method === 'POST') { + const body = await parseJsonBody<{ projectId?: string; agentId?: unknown; title?: unknown }>(req); + sendJson(res, 201, { conversation: await conversations.createConversation(body) }); + return true; + } + } catch (error) { + sendCodingRouteError(res, error); + return true; + } + return false; +} diff --git a/electron/api/routes/coding-route-errors.ts b/electron/api/routes/coding-route-errors.ts new file mode 100644 index 0000000..f601a18 --- /dev/null +++ b/electron/api/routes/coding-route-errors.ts @@ -0,0 +1,42 @@ +import type { ServerResponse } from 'node:http'; +import { CodingProjectServiceError } from '../../coding-projects/project-service'; +import { CodingConversationServiceError } from '../../coding-runtime/conversation-service'; +import { sendJson } from '../route-utils'; + +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, + }); + return; + } + if (error instanceof SyntaxError) { + sendJson(res, 400, { + success: false, + code: 'CODING_REQUEST_INVALID', + error: 'Request JSON is invalid', + }); + return; + } + sendJson(res, 500, { + success: false, + code: 'CODING_REQUEST_FAILED', + error: 'Coding request failed', + }); +} + +export function decodeRouteId(value: string): string { + try { + const decoded = decodeURIComponent(value).trim(); + if (!decoded || decoded.length > 128 || decoded.includes('/')) throw new Error(); + return decoded; + } catch { + throw new CodingConversationServiceError( + 400, + 'CODING_CONVERSATION_REQUEST_INVALID', + 'Route identifier is invalid', + ); + } +} diff --git a/electron/api/routes/providers.ts b/electron/api/routes/providers.ts index 5194559..e6af4e2 100644 --- a/electron/api/routes/providers.ts +++ b/electron/api/routes/providers.ts @@ -268,6 +268,7 @@ async function fetchCurrentUserModelConfig(accessToken: string): Promise { + ctx.codingProducts?.conversations.markProviderStale(); if (ctx.opencodeManager.getStatus().state === 'stopped') return; try { @@ -398,6 +399,9 @@ export async function importCurrentUserModelConfig( signal.throwIfAborted(); await providerService.setDefaultAccount(NIANCODE_USER_MODEL_ACCOUNT_ID); + if (!runtimeRefreshRequired || options.runtimeRefresh === 'defer') { + ctx.codingProducts?.conversations.markProviderStale(); + } signal.throwIfAborted(); if (armStoppedApplyForNextFresh) { lease.markRefreshPending(); diff --git a/electron/coding-projects/conversation-store.ts b/electron/coding-projects/conversation-store.ts index 31ed3cd..31a7c11 100644 --- a/electron/coding-projects/conversation-store.ts +++ b/electron/coding-projects/conversation-store.ts @@ -235,6 +235,20 @@ export function createCodingConversationStore( }); }, + async delete(conversationId: string): Promise { + await mutate(async (file) => { + const current = file.conversations.find((item) => item.id === conversationId); + if (!current) throw new Error('Conversation does not exist'); + return { + result: undefined, + file: { + schemaVersion: 2, + conversations: file.conversations.filter((item) => item.id !== conversationId), + }, + }; + }); + }, + async ensureSessionBinding( conversationId: string, createBinding: () => Promise, diff --git a/electron/coding-projects/project-service.ts b/electron/coding-projects/project-service.ts new file mode 100644 index 0000000..74461c1 --- /dev/null +++ b/electron/coding-projects/project-service.ts @@ -0,0 +1,290 @@ +import { mkdir, readdir, stat, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { isProjectType, type ProjectType } from '../../shared/project-config'; +import { + createCodingConversationStore, + type CodingConversationV2, +} from './conversation-store'; +import { + normalizeCodingProjectConfigV2, + readCodingProjectConfigV2, + writeCodingProjectConfigV2, + type CodingProjectConfigV2, +} from './project-config'; +import { + createLocalCodingProject, + type CodingProject, + type CodingProjectStore, +} from './project-store'; + +const MAX_PROJECT_NAME = 100; +const MAX_KNOWLEDGE_FILE_BYTES = 25 * 1024 * 1024; + +export class CodingProjectServiceError extends Error { + constructor( + readonly status: 400 | 404 | 409, + readonly code: string, + message: string, + ) { + super(message); + this.name = 'CodingProjectServiceError'; + } +} + +export interface CreateCodingProjectRequest { + projectPath?: string; + parentPath?: string; + projectName?: string; + projectType?: ProjectType; +} + +export interface CodingProjectConfigSnapshot { + project: CodingProject; + config: CodingProjectConfigV2; + knowledgeFiles: string[]; +} + +export interface CodingProjectServiceOptions { + onResourcesChanged?(project: CodingProject): Promise | void; + onProjectDeactivated?(project: CodingProject): Promise | void; +} + +function requiredAbsolutePath(value: string | undefined, label: string): string { + const input = value?.trim() ?? ''; + if (!input || !path.isAbsolute(input)) { + throw new CodingProjectServiceError(400, 'CODING_PROJECT_REQUEST_INVALID', `${label} must be an absolute path`); + } + return path.resolve(input); +} + +function projectChildPath(parentPath: string | undefined, projectName: string | undefined): string { + const parent = requiredAbsolutePath(parentPath, 'Project parent path'); + const name = projectName?.trim() ?? ''; + if (!name || name.length > MAX_PROJECT_NAME || name === '.' || name === '..' + || name.includes('/') || name.includes('\\') || name.includes('\0')) { + throw new CodingProjectServiceError(400, 'CODING_PROJECT_REQUEST_INVALID', 'Project name is invalid'); + } + return path.join(parent, name); +} + +function assertStableConfig(previous: CodingProjectConfigV2, next: CodingProjectConfigV2): void { + if (next.projectType !== previous.projectType) { + throw new CodingProjectServiceError(409, 'CODING_PROJECT_TYPE_IMMUTABLE', 'Project type cannot be changed'); + } + if (next.createdAt !== previous.createdAt) { + throw new CodingProjectServiceError(409, 'CODING_PROJECT_IDENTITY_IMMUTABLE', 'Project creation identity cannot be changed'); + } + const nextIds = new Set(next.agents.map(({ id }) => id)); + if (previous.agents.some(({ id }) => !nextIds.has(id))) { + throw new CodingProjectServiceError(409, 'CODING_AGENT_ID_IMMUTABLE', 'Existing Agent ids must be preserved'); + } +} + +export class CodingProjectService { + private readonly conversationStores = new Map< + string, + ReturnType + >(); + + constructor( + private readonly store: CodingProjectStore, + private readonly options: CodingProjectServiceOptions = {}, + ) {} + + listProjects(): Promise { + return this.store.listProjects(); + } + + getActiveProject(): Promise { + return this.store.getActiveProject(); + } + + async requireActiveProject(): Promise { + const project = await this.getActiveProject(); + if (!project) { + throw new CodingProjectServiceError( + 409, + 'CODING_ACTIVE_PROJECT_REQUIRED', + 'No active coding project is selected', + ); + } + return project; + } + + async getProject(projectId: string): Promise { + const id = projectId.trim(); + const project = (await this.store.listProjects()).find((candidate) => candidate.id === id); + if (!project) { + throw new CodingProjectServiceError(404, 'CODING_PROJECT_NOT_FOUND', 'Coding project does not exist'); + } + return project; + } + + async openProject(projectPath: string): Promise { + const resolved = requiredAbsolutePath(projectPath, 'Project path'); + const entry = await stat(resolved).catch(() => null); + if (!entry?.isDirectory()) { + throw new CodingProjectServiceError(400, 'CODING_PROJECT_REQUEST_INVALID', 'Project path is not a directory'); + } + return await this.store.openFolder(resolved); + } + + async createProject(input: CreateCodingProjectRequest): Promise { + const selectedPath = input.projectPath?.trim(); + if (selectedPath && (input.parentPath?.trim() || input.projectName?.trim())) { + throw new CodingProjectServiceError(400, 'CODING_PROJECT_REQUEST_INVALID', 'Project path input is ambiguous'); + } + if (input.projectType !== undefined && !isProjectType(input.projectType)) { + throw new CodingProjectServiceError(400, 'CODING_PROJECT_REQUEST_INVALID', 'Project type is invalid'); + } + const projectPath = selectedPath + ? requiredAbsolutePath(selectedPath, 'Project path') + : projectChildPath(input.parentPath, input.projectName); + if (!selectedPath) { + const existing = await stat(projectPath).catch(() => null); + if (existing) { + 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: [] }; + } 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; + } + } + + async removeProject(projectId: string): Promise { + 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); + this.conversationStores.delete(project.path); + } + + async setActiveProject(projectId: string): Promise { + const project = await this.getProject(projectId); + const config = await readCodingProjectConfigV2(project.path); + if (config.status !== 'valid') { + throw new CodingProjectServiceError( + 409, + 'CODING_PROJECT_CONFIG_INVALID', + '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; + } + + async getConfig(projectId?: string): Promise { + const project = projectId ? await this.getProject(projectId) : await this.requireActiveProject(); + const result = await readCodingProjectConfigV2(project.path); + if (result.status !== 'valid') { + throw new CodingProjectServiceError( + 409, + 'CODING_PROJECT_CONFIG_INVALID', + 'Coding project configuration is unavailable', + ); + } + return { + project, + config: result.config, + knowledgeFiles: await this.listKnowledgeFiles(project.path), + }; + } + + async saveConfig(projectId: string, value: unknown): Promise { + const current = await this.getConfig(projectId); + let next: CodingProjectConfigV2; + 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'); + } + await this.options.onResourcesChanged?.(current.project); + return { + project: current.project, + config: next, + knowledgeFiles: await this.listKnowledgeFiles(current.project.path), + }; + } + + async addKnowledgeFile(input: { + projectId: string; + fileName: string; + contentBase64: string; + }): Promise { + const project = await this.getProject(input.projectId); + await this.getConfig(project.id); + const fileName = input.fileName.trim(); + if (!fileName || fileName !== path.basename(fileName) || fileName === '.' || fileName === '..' + || fileName.includes('\0')) { + throw new CodingProjectServiceError(400, 'CODING_KNOWLEDGE_REQUEST_INVALID', 'Knowledge filename is invalid'); + } + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(input.contentBase64) || input.contentBase64.length % 4 !== 0) { + throw new CodingProjectServiceError(400, 'CODING_KNOWLEDGE_REQUEST_INVALID', 'Knowledge content is invalid'); + } + const content = Buffer.from(input.contentBase64, 'base64'); + if (content.byteLength > MAX_KNOWLEDGE_FILE_BYTES) { + 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 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; + } + await this.options.onResourcesChanged?.(project); + return await this.listKnowledgeFiles(project.path); + } + + conversationStore(projectPath: string): ReturnType { + const existing = this.conversationStores.get(projectPath); + if (existing) return existing; + const created = createCodingConversationStore(projectPath); + this.conversationStores.set(projectPath, created); + return created; + } + + async findActiveConversation(conversationId: string): Promise<{ + project: CodingProject; + conversation: CodingConversationV2; + }> { + const project = await this.requireActiveProject(); + const conversation = await this.conversationStore(project.path).get(conversationId.trim()); + if (!conversation) { + throw new CodingProjectServiceError( + 404, + 'CODING_CONVERSATION_NOT_FOUND', + 'Coding Conversation does not exist', + ); + } + return { project, conversation }; + } + + private async listKnowledgeFiles(projectPath: string): Promise { + try { + const entries = await readdir(path.join(projectPath, 'knowledge'), { withFileTypes: true }); + return entries.filter((entry) => entry.isFile()).map((entry) => entry.name).sort(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } + } +} diff --git a/electron/coding-projects/project-store.ts b/electron/coding-projects/project-store.ts index 3d40065..278bc2c 100644 --- a/electron/coding-projects/project-store.ts +++ b/electron/coding-projects/project-store.ts @@ -68,6 +68,16 @@ export function createCodingProjectStorageFromStore( }; } +export async function createElectronCodingProjectStorage(): Promise { + const Store = (await import('electron-store')).default; + const store = new Store<{ projects?: CodingProjectStoreData }>({ + // Preserve the installed-user storage identity while replacing the + // OpenCode-owned service and types with the product-owned store. + name: 'opencode-projects', + }); + return createCodingProjectStorageFromStore(store, 'projects'); +} + function emptyStoreData(): CodingProjectStoreData { return { projects: {}, activeProjectId: null }; } diff --git a/electron/coding-runtime/contracts.ts b/electron/coding-runtime/contracts.ts index 53aa135..e1c751c 100644 --- a/electron/coding-runtime/contracts.ts +++ b/electron/coding-runtime/contracts.ts @@ -384,6 +384,30 @@ export interface ForkResult { snapshot: ConversationSnapshot; } +export interface CodingRuntimeCommand { + name: string; + description?: string; +} + +export type ConversationInteractionResponse = + | { interactionId: string; cancelled: true } + | { interactionId: string; optionId: string } + | { interactionId: string; confirmed: boolean } + | { interactionId: string; value: string }; + +export interface CodingRuntimeDiagnostics { + revision: { + provider: number; + resources: number; + }; + workers: Array<{ + conversationId: string; + generation: number; + state: 'spawning' | 'ready' | 'queued' | 'running' | 'idle' | 'crashed'; + stage: 'starting' | 'idle' | 'queued' | 'running' | 'failed'; + }>; +} + export interface CodingConversationRuntime { prepare(input: PrepareConversationInput): Promise; getSnapshot(conversationId: string): Promise; @@ -397,5 +421,14 @@ export interface CodingConversationRuntime { fork(input: ForkConversationInput): Promise; recover(conversationId: string): Promise; dispose(conversationId: string): Promise; + listCommands(conversationId: string): Promise; + listInteractions(conversationId?: string): Promise; + respondInteraction( + conversationId: string, + response: ConversationInteractionResponse, + ): Promise; + getDiagnostics(): CodingRuntimeDiagnostics; + markProviderStale(): void; + markResourcesStale(): void; subscribe(listener: (patch: ConversationPatchEnvelope) => void): () => void; } diff --git a/electron/coding-runtime/conversation-service.ts b/electron/coding-runtime/conversation-service.ts new file mode 100644 index 0000000..a2a3000 --- /dev/null +++ b/electron/coding-runtime/conversation-service.ts @@ -0,0 +1,503 @@ +import type { + CodingConversationRuntime, + CodingRuntimeCommand, + CodingRuntimeDiagnostics, + ConversationInteraction, + ConversationInteractionResponse, + ConversationModelState, + ConversationPatchEnvelope, + ConversationSnapshot, + PrepareConversationInput, + ProductModelRef, + PromptAcceptance, + PromptMode, +} from './contracts'; +import type { CodingConversationV2 } from '../coding-projects/conversation-store'; +import { + CodingProjectService, + CodingProjectServiceError, +} from '../coding-projects/project-service'; + +const MAX_TITLE_LENGTH = 200; +const MAX_PROMPT_LENGTH = 100_000; +const MAX_ATTACHMENTS = 16; +const MAX_ACCEPTANCES = 512; +const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const ATTACHMENT_ID_PATTERN = /^[A-Za-z0-9-]{1,64}$/; + +export class CodingConversationServiceError extends Error { + constructor( + readonly status: 400 | 404 | 409 | 503, + readonly code: string, + message: string, + ) { + super(message); + this.name = 'CodingConversationServiceError'; + } +} + +export type CodingPromptAcceptance = PromptAcceptance; + +interface AcceptanceRecord { + fingerprint: string; + flight: Promise; +} + +export type CodingConversationStreamEvent = + | { + type: 'snapshot'; + conversationId: string; + workerGeneration: number; + seq: number; + snapshot: ConversationSnapshot; + } + | ({ type: 'patch' } & ConversationPatchEnvelope); + +export interface CodingConversationEventStream { + snapshots: ConversationSnapshot[]; + events: AsyncIterable; + close(): void; +} + +function requiredString(value: unknown, label: string, maximum: number): string { + const normalized = typeof value === 'string' ? value.trim() : ''; + if (!normalized || normalized.length > maximum) { + throw new CodingConversationServiceError(400, 'CODING_CONVERSATION_REQUEST_INVALID', `${label} is invalid`); + } + return normalized; +} + +function runtimeError(error: unknown): never { + if (error instanceof CodingConversationServiceError || error instanceof CodingProjectServiceError) { + throw error; + } + const publicError = error && typeof error === 'object' + && 'publicError' in error + && error.publicError + && typeof error.publicError === 'object' + ? error.publicError as { code?: unknown; message?: unknown } + : null; + if (publicError && typeof publicError.code === 'string') { + throw new CodingConversationServiceError( + publicError.code === 'CODING_CONVERSATION_NOT_FOUND' ? 404 : 409, + publicError.code, + typeof publicError.message === 'string' ? publicError.message : 'Coding runtime request failed', + ); + } + throw new CodingConversationServiceError(503, 'CODING_RUNTIME_UNAVAILABLE', 'The local coding runtime is unavailable'); +} + +function publicConversation(conversation: CodingConversationV2): CodingConversationV2 { + const { piSessionId: _piSessionId, sessionKey: _sessionKey, ...safe } = conversation; + return safe; +} + +class PatchQueue implements AsyncIterable { + private readonly values: CodingConversationStreamEvent[] = []; + private readonly waiters: Array<(result: IteratorResult) => void> = []; + private closed = false; + + push(value: CodingConversationStreamEvent): void { + if (this.closed) return; + const waiter = this.waiters.shift(); + if (waiter) waiter({ value: structuredClone(value), done: false }); + else this.values.push(structuredClone(value)); + } + + close(): void { + if (this.closed) return; + this.closed = true; + for (const waiter of this.waiters.splice(0)) waiter({ value: undefined, done: true }); + } + + [Symbol.asyncIterator](): AsyncIterator { + return { + next: async () => { + const value = this.values.shift(); + if (value) return { value, done: false }; + if (this.closed) return { value: undefined, done: true }; + return await new Promise>((resolve) => { + this.waiters.push(resolve); + }); + }, + return: async () => { + this.close(); + return { value: undefined, done: true }; + }, + }; + } +} + +export class CodingConversationService { + private readonly prepareFlights = new Map>(); + private readonly acceptances = new Map(); + + constructor( + readonly projects: CodingProjectService, + readonly runtime: CodingConversationRuntime, + ) {} + + async listConversations(projectId?: string): Promise { + const project = projectId + ? await this.projects.getProject(projectId) + : await this.projects.requireActiveProject(); + return (await this.projects.conversationStore(project.path).read()).conversations.map(publicConversation); + } + + async createConversation(input: { + projectId?: string; + agentId: unknown; + title: unknown; + }): Promise { + const project = input.projectId + ? await this.projects.getProject(input.projectId) + : await this.projects.requireActiveProject(); + const config = await this.projects.getConfig(project.id); + const agentId = requiredString(input.agentId, 'Agent id', 64); + const agent = config.config.agents.find((candidate) => ( + candidate.id === agentId && candidate.enabled && !candidate.archivedAt + )); + 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({ + agentId, + title: requiredString(input.title, 'Conversation title', MAX_TITLE_LENGTH), + model: agent.model, + modelResolution: agent.modelResolution, + }); + return publicConversation(created); + } + + async getConversation(conversationId: string): Promise { + const { conversation } = await this.projects.findActiveConversation(conversationId); + return publicConversation(conversation); + } + + async patchConversation(conversationId: string, patch: { + title?: unknown; + archived?: unknown; + unread?: unknown; + }): Promise { + const { project } = await this.projects.findActiveConversation(conversationId); + if (patch.title === undefined && patch.archived === undefined && patch.unread === undefined) { + throw new CodingConversationServiceError(400, 'CODING_CONVERSATION_REQUEST_INVALID', 'Conversation patch is empty'); + } + if (patch.archived !== undefined && typeof patch.archived !== 'boolean') { + throw new CodingConversationServiceError(400, 'CODING_CONVERSATION_REQUEST_INVALID', 'Conversation archive state is invalid'); + } + 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, { + ...(patch.title !== undefined + ? { title: requiredString(patch.title, 'Conversation title', MAX_TITLE_LENGTH) } + : {}), + ...(typeof patch.archived === 'boolean' + ? { 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); + try { + await this.runtime.dispose(conversationId); + } catch (error) { + runtimeError(error); + } + await 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); + } + } + + async getSnapshot(conversationId: string): Promise { + await this.ensurePrepared(conversationId); + try { + return await this.runtime.getSnapshot(conversationId); + } catch (error) { + runtimeError(error); + } + } + + async acceptPrompt(input: { + conversationId: string; + clientRequestId: unknown; + mode: unknown; + text: unknown; + attachments?: unknown; + }): Promise { + const conversationId = requiredString(input.conversationId, 'Conversation id', 128); + const clientRequestId = typeof input.clientRequestId === 'string' ? input.clientRequestId : ''; + if (!REQUEST_ID_PATTERN.test(clientRequestId)) { + throw new CodingConversationServiceError(400, 'CODING_CONVERSATION_REQUEST_INVALID', 'Client request id is invalid'); + } + if (!['prompt', 'steer', 'follow-up'].includes(String(input.mode))) { + throw new CodingConversationServiceError(400, 'CODING_CONVERSATION_REQUEST_INVALID', 'Prompt mode is invalid'); + } + if (typeof input.text !== 'string' || input.text.length > MAX_PROMPT_LENGTH) { + throw new CodingConversationServiceError(400, 'CODING_CONVERSATION_REQUEST_INVALID', 'Prompt text is invalid'); + } + const attachments = Array.isArray(input.attachments) ? input.attachments : []; + if (attachments.length > MAX_ATTACHMENTS || attachments.some((item) => ( + !item || typeof item !== 'object' || Array.isArray(item) + || !ATTACHMENT_ID_PATTERN.test(String((item as { attachmentId?: unknown }).attachmentId ?? '')) + ))) { + throw new CodingConversationServiceError(400, 'CODING_CONVERSATION_REQUEST_INVALID', 'Prompt attachments are invalid'); + } + if (!input.text.trim() && attachments.length === 0) { + throw new CodingConversationServiceError(400, 'CODING_CONVERSATION_REQUEST_INVALID', 'Prompt content is empty'); + } + const mode = input.mode as PromptMode; + const normalizedAttachments = attachments.map((item) => ({ + attachmentId: String((item as { attachmentId: unknown }).attachmentId), + })); + const fingerprint = JSON.stringify({ mode, text: input.text, attachments: normalizedAttachments }); + const key = `${conversationId}\u0000${clientRequestId}`; + const prior = this.acceptances.get(key); + if (prior) { + if (prior.fingerprint !== fingerprint) { + throw new CodingConversationServiceError(409, 'CODING_REQUEST_ID_CONFLICT', 'Client request id was already used'); + } + return await prior.flight; + } + const flight = (async (): Promise => { + try { + await this.ensurePrepared(conversationId); + return await this.runtime.prompt({ + conversationId, + clientRequestId, + mode, + text: input.text as string, + attachments: normalizedAttachments, + }); + } catch (error) { + const publicCode = error && typeof error === 'object' + && 'publicError' in error + && error.publicError + && typeof error.publicError === 'object' + && 'code' in error.publicError + ? String(error.publicError.code) + : ''; + if (publicCode !== 'CODING_REQUEST_UNCERTAIN') this.acceptances.delete(key); + runtimeError(error); + } + })(); + this.acceptances.set(key, { fingerprint, flight }); + this.trimAcceptances(); + return await flight; + } + + async abort(conversationId: string): Promise { + await this.ensurePrepared(conversationId); + try { await this.runtime.abort(conversationId); } catch (error) { runtimeError(error); } + } + + async setModel(conversationId: string, model: ProductModelRef): Promise { + await this.ensurePrepared(conversationId); + const { project } = await this.projects.findActiveConversation(conversationId); + 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; + } catch (error) { runtimeError(error); } + } + + async setThinking(conversationId: string, thinkingLevel: ProductModelRef['thinkingLevel']): Promise { + await this.ensurePrepared(conversationId); + const { project } = await this.projects.findActiveConversation(conversationId); + try { + const state = await this.runtime.setThinking({ conversationId, thinkingLevel }); + await this.projects.conversationStore(project.path).setModelState(conversationId, state); + return state; + } catch (error) { runtimeError(error); } + } + + async compact(conversationId: string): Promise { + await this.ensurePrepared(conversationId); + try { await this.runtime.compact(conversationId); } catch (error) { runtimeError(error); } + } + + async recover(conversationId: string): Promise { + await this.ensurePrepared(conversationId); + try { await this.runtime.recover(conversationId); } catch (error) { runtimeError(error); } + } + + 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({ + agentId: source.conversation.agentId, + title: `${source.conversation.title} (fork)`, + model: source.conversation.model, + modelResolution: source.conversation.modelResolution, + }); + try { + await this.runtime.fork({ + sourceConversationId, + ...(sourceEntryId ? { sourceEntryId } : {}), + conversation: { + ...prepared, + conversationId: created.id, + title: created.title, + model: { + model: created.model, + modelResolution: created.modelResolution, + }, + }, + }); + return publicConversation(created); + } catch (error) { + await this.projects.conversationStore(source.project.path).delete(created.id).catch(() => undefined); + runtimeError(error); + } + } + + async listInteractions(conversationId?: string): Promise { + if (conversationId) await this.projects.findActiveConversation(conversationId); + try { return await this.runtime.listInteractions(conversationId); } catch (error) { runtimeError(error); } + } + + async respondInteraction(conversationId: string, response: ConversationInteractionResponse): Promise { + const id = requiredString(conversationId, 'Conversation id', 128); + if ('optionId' in response && (response.optionId.length === 0 || response.optionId.length > 256)) { + throw new CodingConversationServiceError(400, 'CODING_INTERACTION_REQUEST_INVALID', 'Interaction option is invalid'); + } + if ('value' in response && response.value.length > MAX_PROMPT_LENGTH) { + throw new CodingConversationServiceError(400, 'CODING_INTERACTION_REQUEST_INVALID', 'Interaction value is invalid'); + } + await this.projects.findActiveConversation(id); + try { await this.runtime.respondInteraction(id, response); } catch (error) { runtimeError(error); } + } + + async listLiveCommands(conversationId: string): Promise { + await this.projects.findActiveConversation(conversationId); + try { return await this.runtime.listCommands(conversationId); } catch { return []; } + } + + getDiagnostics(): CodingRuntimeDiagnostics { + return this.runtime.getDiagnostics(); + } + + markProviderStale(): void { this.runtime.markProviderStale(); } + markResourcesStale(): void { this.runtime.markResourcesStale(); } + + async openEventStream(conversationId?: string): Promise { + const id = conversationId === undefined + ? undefined + : requiredString(conversationId, 'Conversation id', 128); + if (id) await this.projects.findActiveConversation(id); + const queue = new PatchQueue(); + const cursors = new Map(); + let initialize!: () => void; + const initialized = new Promise((resolve) => { initialize = resolve; }); + let eventTail = Promise.resolve(); + const unsubscribe = this.runtime.subscribe((event) => { + if (id && event.conversationId !== id) return; + eventTail = eventTail.then(async () => { + await initialized; + const cursor = cursors.get(event.conversationId); + if (cursor && event.workerGeneration === cursor.workerGeneration && event.seq <= cursor.seq) return; + if (!cursor + || event.workerGeneration !== cursor.workerGeneration + || event.seq !== cursor.seq + 1) { + try { + const snapshot = await this.runtime.getSnapshot(event.conversationId); + cursors.set(event.conversationId, snapshot.cursor); + queue.push({ + type: 'snapshot', + conversationId: event.conversationId, + workerGeneration: snapshot.cursor.workerGeneration, + seq: snapshot.cursor.seq, + snapshot, + }); + } catch { + // A disposed Conversation has no reconnect state to publish. + } + return; + } + cursors.set(event.conversationId, { + workerGeneration: event.workerGeneration, + seq: event.seq, + }); + queue.push({ type: 'patch', ...event }); + }).catch(() => undefined); + }); + try { + const snapshots = id + ? [await this.getSnapshot(id)] + : (await Promise.all(this.runtime.getDiagnostics().workers.map(async ({ conversationId: target }) => { + try { return await this.runtime.getSnapshot(target); } catch { return null; } + }))).filter((snapshot): snapshot is ConversationSnapshot => snapshot !== null); + for (const snapshot of snapshots) { + cursors.set(snapshot.conversation.id, snapshot.cursor); + } + initialize(); + return { + snapshots, + events: queue, + close: () => { + unsubscribe(); + queue.close(); + }, + }; + } catch (error) { + initialize(); + unsubscribe(); + queue.close(); + throw error; + } + } + + private ensurePrepared(conversationId: string): Promise { + const id = conversationId.trim(); + const prior = this.prepareFlights.get(id); + if (prior) return prior; + const flight = (async () => { + const { project, conversation } = await this.projects.findActiveConversation(id); + const config = await this.projects.getConfig(project.id); + const agent = config.config.agents.find((candidate) => ( + candidate.id === conversation.agentId && candidate.enabled && !candidate.archivedAt + )); + if (!agent) { + throw new CodingConversationServiceError(409, 'CODING_AGENT_NOT_FOUND', 'Conversation Agent is unavailable'); + } + const prepared: PrepareConversationInput = { + conversationId: conversation.id, + projectId: project.id, + agentId: conversation.agentId, + title: conversation.title, + model: { + model: conversation.model, + modelResolution: conversation.modelResolution, + }, + }; + try { await this.runtime.prepare(prepared); } catch (error) { runtimeError(error); } + return prepared; + })().finally(() => { + if (this.prepareFlights.get(id) === flight) this.prepareFlights.delete(id); + }); + this.prepareFlights.set(id, flight); + 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); + } + } +} diff --git a/electron/coding-runtime/in-memory-conversation-runtime.ts b/electron/coding-runtime/in-memory-conversation-runtime.ts index 9ed22f4..f84cc57 100644 --- a/electron/coding-runtime/in-memory-conversation-runtime.ts +++ b/electron/coding-runtime/in-memory-conversation-runtime.ts @@ -1,8 +1,12 @@ import type { CodingConversationRuntime, + CodingRuntimeCommand, + CodingRuntimeDiagnostics, CodingRuntimeErrorCode, CodingRuntimePublicError, ConversationModelState, + ConversationInteraction, + ConversationInteractionResponse, ConversationPatch, ConversationPatchEnvelope, ConversationRuntimeState, @@ -35,6 +39,7 @@ export class CodingRuntimeContractError extends Error { export interface InMemoryConversationRuntimeOptions { snapshots?: ConversationSnapshot[]; + commands?: CodingRuntimeCommand[]; now?: () => number; createId?: (kind: 'run' | 'node' | 'queue' | 'compaction') => string; } @@ -79,11 +84,15 @@ export class InMemoryConversationRuntime implements CodingConversationRuntime { private readonly promptAcceptances = new Map(); private readonly queueAcceptances = new Map(); private readonly now: () => number; + private readonly commands: CodingRuntimeCommand[]; private readonly createId: InMemoryConversationRuntimeOptions['createId']; private nextId = 0; + private providerRevision = 1; + private resourcesRevision = 1; constructor(options: InMemoryConversationRuntimeOptions = {}) { this.now = options.now ?? Date.now; + this.commands = clone(options.commands ?? []); this.createId = options.createId; for (const snapshot of options.snapshots ?? []) { const state = createConversationReducerState(snapshot); @@ -176,6 +185,19 @@ export class InMemoryConversationRuntime implements CodingConversationRuntime { } async prompt(input: PromptConversationInput): Promise { + if (input.mode === 'steer' || input.mode === 'follow-up') { + const acceptance = input.mode === 'steer' + ? await this.steer(input) + : await this.followUp(input); + return { + accepted: true, + conversationId: input.conversationId, + clientRequestId: input.clientRequestId, + runId: this.snapshot(input.conversationId).run.runId ?? this.id('run'), + mode: input.mode, + queuePosition: acceptance.queuePosition, + }; + } const key = `${input.conversationId}:${input.clientRequestId}`; const prior = this.promptAcceptances.get(key); if (prior) return clone(prior); @@ -432,6 +454,7 @@ export class InMemoryConversationRuntime implements CodingConversationRuntime { } async dispose(conversationId: string): Promise { + if (!this.states.has(conversationId)) return; const snapshot = this.snapshot(conversationId); this.replaceSnapshot({ ...snapshot, @@ -444,6 +467,81 @@ export class InMemoryConversationRuntime implements CodingConversationRuntime { }); } + async listCommands(conversationId: string): Promise { + return this.states.has(conversationId) ? clone(this.commands) : []; + } + + async listInteractions(conversationId?: string): Promise { + return [...this.states.values()].flatMap((state) => { + const snapshot = state.snapshot; + if (!snapshot || (conversationId && snapshot.conversation.id !== conversationId)) return []; + return clone(snapshot.pendingInteractions); + }); + } + + async respondInteraction( + conversationId: string, + response: ConversationInteractionResponse, + ): Promise { + const interaction = this.snapshot(conversationId).pendingInteractions.find( + ({ id }) => id === response.interactionId, + ); + if (!interaction) { + throw new CodingRuntimeContractError( + 'CODING_RUNTIME_PROTOCOL_ERROR', + 'Conversation interaction is not pending', + true, + ); + } + this.emit(conversationId, { op: 'interaction.remove', interactionId: interaction.id }, interaction.runId); + } + + getDiagnostics(): CodingRuntimeDiagnostics { + return { + revision: { + provider: this.providerRevision, + resources: this.resourcesRevision, + }, + workers: [...this.states.values()].flatMap((state) => { + const snapshot = state.snapshot; + if (!snapshot) return []; + const runtimeState = snapshot.worker.status === 'error' + ? 'crashed' as const + : snapshot.worker.status === 'starting' || snapshot.worker.status === 'recovering' + ? 'spawning' as const + : snapshot.worker.status === 'stopped' + ? 'idle' as const + : snapshot.run.status === 'queued' + ? 'queued' as const + : snapshot.run.status === 'running' + ? 'running' as const + : 'ready' as const; + return [{ + conversationId: snapshot.conversation.id, + generation: snapshot.worker.generation, + state: runtimeState, + stage: runtimeState === 'crashed' + ? 'failed' as const + : runtimeState === 'spawning' + ? 'starting' as const + : runtimeState === 'queued' + ? 'queued' as const + : runtimeState === 'running' + ? 'running' as const + : 'idle' as const, + }]; + }), + }; + } + + markProviderStale(): void { + this.providerRevision += 1; + } + + markResourcesStale(): void { + this.resourcesRevision += 1; + } + subscribe(listener: (patch: ConversationPatchEnvelope) => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); diff --git a/electron/coding-runtime/pi/interaction.ts b/electron/coding-runtime/pi/interaction.ts index b651dd6..987e390 100644 --- a/electron/coding-runtime/pi/interaction.ts +++ b/electron/coding-runtime/pi/interaction.ts @@ -1,4 +1,7 @@ -import type { ConversationInteraction } from '../contracts'; +import type { + ConversationInteraction, + ConversationInteractionResponse, +} from '../contracts'; import type { PiRpcCommand, PiRpcEvent } from './rpc-client'; import type { PiGenerationResourceInput, PiWorkerPoolState } from './worker-pool'; @@ -17,12 +20,6 @@ interface StoredInteraction { untrack(): void; } -export type PiInteractionResponse = - | { interactionId: string; cancelled: true } - | { interactionId: string; optionId: string } - | { interactionId: string; confirmed: boolean } - | { interactionId: string; value: string }; - function dialogEvent(event: PiRpcEvent): event is PiRpcEvent & { id: string; method: 'select' | 'confirm' | 'input' | 'editor'; @@ -101,7 +98,7 @@ export class PiInteractionStore { return structuredClone(interaction); } - async respond(conversationId: string, response: PiInteractionResponse): Promise { + async respond(conversationId: string, response: ConversationInteractionResponse): Promise { const stored = this.pending.get(this.key(conversationId, response.interactionId)); if (!stored) throw new Error('Pi interaction is not pending'); if (stored.phase === 'responding') throw new Error('Pi interaction response is already in progress'); diff --git a/electron/coding-runtime/pi/runtime.ts b/electron/coding-runtime/pi/runtime.ts index 6c435df..5562de8 100644 --- a/electron/coding-runtime/pi/runtime.ts +++ b/electron/coding-runtime/pi/runtime.ts @@ -12,7 +12,11 @@ import { import { PiProviderRefreshCoordinator } from './provider-refresh'; import type { CodingConversationRuntime, + CodingRuntimeCommand, + CodingRuntimeDiagnostics, CodingRuntimePublicError, + ConversationInteraction, + ConversationInteractionResponse, ConversationModelState, ConversationPatch, ConversationPatchEnvelope, @@ -78,7 +82,6 @@ import { PiManagedExtensionHost } from './extension-host'; import type { PiSubagentScheduler } from './subagent'; import { PiInteractionStore, - type PiInteractionResponse, } from './interaction'; import { PiExtensionUiProjector, @@ -836,6 +839,52 @@ export class PiConversationRuntime implements CodingConversationRuntime { this.hydrationFlights.delete(conversationId); } + async listCommands(conversationId: string): Promise { + const state = this.pool.getState(conversationId); + if (!state || state.state === 'spawning' || state.state === 'crashed') return []; + const response = await this.pool.request( + conversationId, + { type: 'get_commands' }, + { retry: 'read-only-once' }, + ); + const record = response.data && typeof response.data === 'object' && !Array.isArray(response.data) + ? response.data as Record + : null; + const candidates = Array.isArray(response.data) + ? response.data + : Array.isArray(record?.commands) + ? record.commands + : []; + return candidates.flatMap((candidate) => { + if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return []; + const command = candidate as Record; + const name = typeof command.name === 'string' ? command.name.trim() : ''; + if (!name) return []; + return [{ + name, + ...(typeof command.description === 'string' + ? { description: command.description } + : {}), + }]; + }); + } + + async listInteractions(conversationId?: string): Promise { + return this.interactions.list(conversationId); + } + + getDiagnostics(): CodingRuntimeDiagnostics { + return this.pool.getDiagnostics(); + } + + markProviderStale(): void { + this.pool.markProviderStale(); + } + + markResourcesStale(): void { + this.pool.markResourcesStale(); + } + subscribe(listener: (patch: ConversationPatchEnvelope) => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); @@ -843,7 +892,7 @@ export class PiConversationRuntime implements CodingConversationRuntime { async respondInteraction( conversationId: string, - response: PiInteractionResponse, + response: ConversationInteractionResponse, ): Promise { await this.interactions.respond(conversationId, response); } diff --git a/electron/coding-runtime/pi/session-registry.ts b/electron/coding-runtime/pi/session-registry.ts index 0042499..19d165d 100644 --- a/electron/coding-runtime/pi/session-registry.ts +++ b/electron/coding-runtime/pi/session-registry.ts @@ -114,7 +114,9 @@ export class PiSessionRegistry { if (!project) throw new Error('Coding project does not exist'); const configRead = await readCodingProjectConfigV2(project.path); if (configRead.status !== 'valid') throw new Error('Coding project configuration is unavailable'); - const agent = configRead.config.agents.find((candidate) => candidate.id === input.agentId); + const agent = configRead.config.agents.find((candidate) => ( + candidate.id === input.agentId && candidate.enabled && !candidate.archivedAt + )); if (!agent) throw new Error('Coding Agent does not exist'); const store = createCodingConversationStore(project.path); const conversation = await store.get(input.conversationId); diff --git a/electron/coding-runtime/pi/worker-pool.ts b/electron/coding-runtime/pi/worker-pool.ts index cdce787..d09b6af 100644 --- a/electron/coding-runtime/pi/worker-pool.ts +++ b/electron/coding-runtime/pi/worker-pool.ts @@ -1,5 +1,8 @@ -import type { PrepareConversationInput } from '../contracts'; -import type { ConversationModelState } from '../contracts'; +import type { + CodingRuntimeDiagnostics, + ConversationModelState, + PrepareConversationInput, +} from '../contracts'; import type { PiProcessError, PiProcessErrorCode } from './process-errors'; import type { PiRpcCommand, @@ -347,6 +350,27 @@ export class PiWorkerPool { return record ? this.publicState(record) : null; } + getDiagnostics(): CodingRuntimeDiagnostics { + const stage = ( + state: PiWorkerPoolState['state'], + ): CodingRuntimeDiagnostics['workers'][number]['stage'] => { + if (state === 'spawning') return 'starting'; + if (state === 'queued') return 'queued'; + if (state === 'running') return 'running'; + if (state === 'crashed') return 'failed'; + return 'idle'; + }; + return { + revision: this.revisions.current, + workers: [...this.workers.values()].map((record) => ({ + conversationId: record.conversation.conversationId, + generation: record.generation, + state: record.state, + stage: stage(record.state), + })), + }; + } + async reclaimIdleWorker(signal?: AbortSignal): Promise { while (true) { if (signal?.aborted) throw new Error('Pi idle worker reclaim cancelled'); diff --git a/electron/main/index.ts b/electron/main/index.ts index db95cd8..3b15065 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -18,7 +18,6 @@ import { resolveBundledCourseSkillsDir, } from '../opencode/course-skills'; import { - createElectronProjectStorage, createProjectStore, type OpencodeProjectStore, } from '../opencode/project-store'; @@ -95,10 +94,13 @@ import { } from '../image-workspace/local-workspace'; import { WorksSquareDesignWorkspace } from '../image-workspace/works-square-workspace'; import type { DesignWorkspaceModule } from '../image-workspace/module'; -import { CodingAttachmentStore } from '../coding-projects/attachment-store'; -import { PiProductTools } from '../coding-runtime/pi/product-tools'; +import { createElectronCodingProjectStorage } from '../coding-projects/project-store'; import { resolveBundledCodingSkillsDir } from '../coding-runtime/pi/resource-loader'; -import { createCodingProductHost } from '../api/coding-product-services'; +import type { CodingProductComposition } from '../api/coding-product-services'; +import { + createCodingComposition, + resolveCodingPiRuntimePaths, +} from '../api/coding-composition'; // Diagnostic package: force Chromium networking onto HTTP/1.1 for transport A/B testing. app.commandLine.appendSwitch('disable-http2'); @@ -206,6 +208,7 @@ let applicationInitialization: Promise | null = null; let imageWorkspaceModule: DesignWorkspaceModule | null = null; let backgroundLifecycle!: BackgroundLifecycleController; let releaseJobs: ReleaseJobManager | null = null; +let codingProducts: CodingProductComposition | null = null; const mainWindowFocusState = createMainWindowFocusState(); const quitLifecycleState = createQuitLifecycleState(); const launchDeepLinkUrl = findNianCodeDeepLinkUrl(process.argv); @@ -496,7 +499,8 @@ async function initialize(): Promise { // Only local storage and the lightweight workspace object are needed to // construct the first window. Authentication and remote setup are restored // after the renderer has had a chance to paint. - opencodeProjectStore = createProjectStore(await createElectronProjectStorage()); + const codingProjectStorage = await createElectronCodingProjectStorage(); + opencodeProjectStore = createProjectStore(codingProjectStorage); worksSubmissionBinding = createWorksSubmissionBindingStore(opencodeProjectStore); const localImageWorkspaceEnabled = isLocalImageWorkspaceDevelopmentEnabled({ @@ -553,29 +557,25 @@ async function initialize(): Promise { window.webContents.send('release-job:status', status); } }); - const codingAttachments = new CodingAttachmentStore( - join(app.getPath('userData'), 'coding-runtime', 'attachments'), - ); - const piProductTools = new PiProductTools({ + codingProducts = createCodingComposition({ + storage: codingProjectStorage, browser: agentBrowser, - attachments: codingAttachments, - bundledSkillsDir: resolveBundledCodingSkillsDir({ - isPackaged: app.isPackaged, - resourcesPath: process.resourcesPath, - appPath: app.getAppPath(), - }), + localProxyCredential: getHostApiToken() || undefined, + paths: { + ...resolveCodingPiRuntimePaths({ + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + appPath: app.getAppPath(), + executablePath: process.execPath, + }), + userDataDir: app.getPath('userData'), + bundledSkillsDir: resolveBundledCodingSkillsDir({ + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + appPath: app.getAppPath(), + }), + }, }); - const codingProducts = { - attachments: codingAttachments, - productTools: piProductTools, - host: createCodingProductHost({ - productTools: piProductTools, - getActiveProject: async () => { - const project = await opencodeProjectStore.getActiveProject(); - return project ? { id: project.id, path: project.path } : null; - }, - }), - }; const hostApiContext: HostApiContext = { opencodeManager, opencodeProjectStore, @@ -866,10 +866,14 @@ if (gotTheLock) { const closeImageWorkspacePromise = imageWorkspaceModule?.closeEventSessions?.().catch((err) => { logger.warn('imageWorkspace.closeEventSessions() error during quit:', err); }) ?? Promise.resolve(); + const stopCodingProductsPromise = codingProducts?.shutdown().catch((err) => { + logger.warn('codingProducts.shutdown() error during quit:', err); + }) ?? Promise.resolve(); const stopPromise = Promise.allSettled([ stopOpencodePromise, stopAgentBrowserPromise, closeImageWorkspacePromise, + stopCodingProductsPromise, ]); const timeoutPromise = new Promise<'timeout'>((resolve) => { setTimeout(() => resolve('timeout'), 5000); @@ -900,6 +904,11 @@ if (gotTheLock) { } catch { // ignore — stop() may not be callable if state is corrupted } + try { + void codingProducts?.shutdown().catch(() => { /* ignore */ }); + } catch { + // ignore — shutdown() may not be callable if state is corrupted + } // Give runtime stop a brief window, then force-exit. setTimeout(() => { process.exit(1); diff --git a/tests/unit/coding-core-routes.test.ts b/tests/unit/coding-core-routes.test.ts new file mode 100644 index 0000000..4c8c306 --- /dev/null +++ b/tests/unit/coding-core-routes.test.ts @@ -0,0 +1,391 @@ +// @vitest-environment node + +import { mkdtemp, rm } from 'node:fs/promises'; +import { createServer, type Server } from 'node:http'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +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 { handleCodingConversationRoutes } from '../../electron/api/routes/coding-conversations'; +import type { AgentBrowserModule } from '../../electron/agent-browser'; +import { + CodingConversationService, +} from '../../electron/coding-runtime/conversation-service'; +import { + CodingRuntimeContractError, + InMemoryConversationRuntime, +} from '../../electron/coding-runtime/in-memory-conversation-runtime'; +import { CodingProjectService } from '../../electron/coding-projects/project-service'; +import { + createCodingProjectAgent, +} from '../../electron/coding-projects/project-config'; +import { + createCodingProjectStore, + createLocalCodingProject, + createMemoryCodingProjectStorage, +} from '../../electron/coding-projects/project-store'; +import type { PromptConversationInput } from '../../electron/coding-runtime/contracts'; + +const roots: string[] = []; +const servers: Server[] = []; +const MODEL = { + accountId: 'account-a', + modelId: 'model-a', + thinkingLevel: 'medium' as const, +}; + +afterEach(async () => { + await Promise.all(servers.splice(0).map(async (server) => { + await new Promise((resolve) => server.close(() => resolve())); + })); + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +async function setup(runtime = new InMemoryConversationRuntime({ + commands: [{ name: 'live-command', description: 'From live worker' }], +})) { + const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-core-')); + roots.push(root); + const store = createCodingProjectStore(createMemoryCodingProjectStorage(), { + createId: () => 'project-a', + now: () => '2026-08-23T00:00:00.000Z', + }); + await createLocalCodingProject({ + projectPath: root, + now: '2026-08-23T00:00:00.000Z', + }, store); + await createCodingProjectAgent(root, { + id: 'builder', + avatarId: 'avatar-01', + roleName: '实现者', + name: 'Builder', + model: MODEL, + modelResolution: 'resolved', + responsibility: { + mission: 'Implement', owns: [], boundaries: [], collaborators: [], principles: [], + }, + }, { now: '2026-08-23T00:00:00.000Z' }); + const projects = new CodingProjectService(store); + const conversations = new CodingConversationService(projects, runtime); + return { root, projects, conversations, runtime }; +} + +function context(setupResult: Awaited>): HostApiContext { + return { + codingProducts: { + projects: setupResult.projects, + conversations: setupResult.conversations, + runtime: setupResult.runtime, + }, + } as unknown as HostApiContext; +} + +async function createConversation( + conversations: CodingConversationService, +) { + return await conversations.createConversation({ + agentId: 'builder', + title: 'PI-100', + }); +} + +describe('PI-100 coding core Host contract', () => { + it('uses one vendor-neutral Main composition without spawning on create', async () => { + const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-pi-composition-project-')); + const userDataDir = await mkdtemp(path.join(tmpdir(), 'makelore-pi-composition-user-')); + roots.push(projectPath, userDataDir); + const composition = createCodingComposition({ + storage: createMemoryCodingProjectStorage(), + browser: { close: vi.fn(async () => undefined) } as unknown as AgentBrowserModule, + paths: { + executablePath: process.execPath, + cliPath: path.join(projectPath, 'unused-cli.js'), + userDataDir, + bundledSkillsDir: path.resolve('resources/coding-skills'), + }, + }); + try { + const created = await composition.projects.createProject({ projectPath }); + await createCodingProjectAgent(projectPath, { + id: 'builder', + avatarId: 'avatar-01', + roleName: '实现者', + name: 'Builder', + model: MODEL, + modelResolution: 'resolved', + responsibility: { + mission: 'Implement', owns: [], boundaries: [], collaborators: [], principles: [], + }, + }); + const conversation = await composition.conversations.createConversation({ + projectId: created.project.id, + agentId: 'builder', + title: 'Local only', + }); + expect(composition.runtime.getDiagnostics().workers).toEqual([]); + expect(await composition.host.listCommands(conversation.id)).toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'compact', source: 'makelore' })]), + ); + expect(composition.runtime.getDiagnostics().workers).toEqual([]); + } finally { + await composition.shutdown(); + } + }); + + it('keeps project and Conversation metadata operations local-only', async () => { + const result = await setup(); + const prepare = vi.spyOn(result.runtime, 'prepare'); + const conversation = await createConversation(result.conversations); + await result.conversations.listConversations('project-a'); + await result.conversations.getConversation(conversation.id); + await result.conversations.patchConversation(conversation.id, { title: 'Renamed' }); + await result.conversations.deleteConversation(conversation.id); + expect(prepare).not.toHaveBeenCalled(); + expect(result.conversations.getDiagnostics().workers).toEqual([]); + }); + + it('returns 202 acceptance, deduplicates requests, and exposes only safe diagnostics', async () => { + const result = await setup(); + const conversation = await createConversation(result.conversations); + const prompt = vi.spyOn(result.runtime, 'prompt'); + const request = { + path: `/api/coding/conversations/${conversation.id}/prompt`, + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + clientRequestId: 'request-1', mode: 'prompt', text: 'Implement it', attachments: [], + }), + }; + const [first, duplicate] = await Promise.all([ + dispatchHostApiRequest(context(result), request), + dispatchHostApiRequest(context(result), request), + ]); + expect(first).toMatchObject({ + status: 202, + json: { + acceptance: { + accepted: true, + clientRequestId: 'request-1', + runId: expect.any(String), + mode: 'prompt', + }, + }, + }); + expect(duplicate).toEqual(first); + expect(prompt).toHaveBeenCalledTimes(1); + + const conflict = await dispatchHostApiRequest(context(result), { + ...request, + body: JSON.stringify({ + clientRequestId: 'request-1', mode: 'prompt', text: 'Different', attachments: [], + }), + }); + expect(conflict).toMatchObject({ + status: 409, + json: { code: 'CODING_REQUEST_ID_CONFLICT' }, + }); + const diagnostics = await dispatchHostApiRequest(context(result), { + path: '/api/coding/runtime/diagnostics', + }); + expect(diagnostics).toMatchObject({ + status: 200, + json: { runtime: { revision: { provider: 1, resources: 1 } } }, + }); + expect(JSON.stringify(diagnostics.json)).not.toMatch(/session|workerId|apiKey|providerPath/i); + }); + + it('opens an event stream snapshot-first and never replays a prompt', async () => { + const result = await setup(); + const conversation = await createConversation(result.conversations); + const stream = await result.conversations.openEventStream(conversation.id); + expect(stream.snapshots[0]).toMatchObject({ + conversation: { id: conversation.id }, + cursor: { workerGeneration: 0, seq: 0 }, + }); + await result.conversations.acceptPrompt({ + conversationId: conversation.id, + clientRequestId: 'request-stream', + mode: 'prompt', + text: 'Stream this', + attachments: [], + }); + const iterator = stream.events[Symbol.asyncIterator](); + const firstPatch = await iterator.next(); + expect(firstPatch).toMatchObject({ + done: false, + value: { + type: 'patch', + conversationId: conversation.id, + workerGeneration: 0, + seq: 1, + patch: { op: 'message.upsert' }, + }, + }); + stream.close(); + + const globalStream = await result.conversations.openEventStream(); + expect(globalStream.snapshots.map((snapshot) => snapshot.conversation.id)).toContain(conversation.id); + await result.conversations.acceptPrompt({ + conversationId: conversation.id, + clientRequestId: 'request-global-stream', + mode: 'follow-up', + text: 'Keep streaming', + attachments: [], + }); + await expect(globalStream.events[Symbol.asyncIterator]().next()).resolves.toMatchObject({ + done: false, + value: { type: 'patch', conversationId: conversation.id }, + }); + globalStream.close(); + }); + + it('streams Host SSE snapshot before target patches', async () => { + const result = await setup(); + const conversation = await createConversation(result.conversations); + const hostContext = context(result); + const server = createServer((request, response) => { + const url = new URL(request.url ?? '/', 'http://127.0.0.1'); + void handleCodingConversationRoutes(request, response, url, hostContext).then((handled) => { + if (!handled && !response.writableEnded) { + response.statusCode = 404; + response.end(); + } + }); + }); + servers.push(server); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('SSE test server did not bind'); + const controller = new AbortController(); + const response = await fetch( + `http://127.0.0.1:${address.port}/api/coding/events?conversationId=${conversation.id}`, + { signal: controller.signal }, + ); + expect(response.status).toBe(200); + const reader = response.body?.getReader(); + if (!reader) throw new Error('SSE response has no body'); + const decoder = new TextDecoder(); + let buffer = ''; + const nextEvent = async (): Promise => { + while (!buffer.includes('\n\n')) { + const next = await reader.read(); + if (next.done) throw new Error('SSE stream ended before the next event'); + buffer += decoder.decode(next.value, { stream: true }); + } + const boundary = buffer.indexOf('\n\n'); + const event = buffer.slice(0, boundary); + buffer = buffer.slice(boundary + 2); + return event; + }; + expect(await nextEvent()).toContain('event: snapshot'); + await result.conversations.acceptPrompt({ + conversationId: conversation.id, + clientRequestId: 'request-host-sse', + mode: 'prompt', + text: 'Host to SSE', + attachments: [], + }); + const patchEvent = await nextEvent(); + expect(patchEvent).toContain('event: patch'); + expect(patchEvent).toContain(`"conversationId":"${conversation.id}"`); + controller.abort(); + await reader.cancel().catch(() => undefined); + }); + + it('degrades live commands before worker prepare and projects them after prepare', async () => { + const result = await setup(); + const conversation = await createConversation(result.conversations); + expect(await result.conversations.listLiveCommands(conversation.id)).toEqual([]); + await result.conversations.getSnapshot(conversation.id); + expect(await result.conversations.listLiveCommands(conversation.id)).toEqual([ + { name: 'live-command', description: 'From live worker' }, + ]); + }); + + it('correlates interaction responses by the route id', async () => { + const result = await setup(); + const conversation = await createConversation(result.conversations); + vi.spyOn(result.runtime, 'listInteractions').mockResolvedValue([{ + id: 'question-1', + conversationId: conversation.id, + runId: 'run-1', + kind: 'select', + title: 'Choose', + options: [{ id: 'option-1', label: 'One' }], + status: 'pending', + }]); + const respond = vi.spyOn(result.runtime, 'respondInteraction').mockResolvedValue(); + expect(await dispatchHostApiRequest(context(result), { + path: `/api/coding/interactions?conversationId=${conversation.id}`, + })).toMatchObject({ + status: 200, + json: { interactions: [{ id: 'question-1', status: 'pending' }] }, + }); + expect(await dispatchHostApiRequest(context(result), { + path: '/api/coding/interactions/question-1/respond', + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ conversationId: conversation.id, optionId: 'option-1' }), + })).toMatchObject({ status: 204 }); + expect(respond).toHaveBeenCalledWith(conversation.id, { + interactionId: 'question-1', + optionId: 'option-1', + }); + }); + + it('redacts unknown runtime failures from Host responses', async () => { + const result = await setup(); + const conversation = await createConversation(result.conversations); + const privateFailure = `stderr token=secret path=${result.root}`; + vi.spyOn(result.runtime, 'getSnapshot').mockRejectedValue(new Error(privateFailure)); + const response = await dispatchHostApiRequest(context(result), { + path: `/api/coding/conversations/${conversation.id}/snapshot`, + }); + expect(response).toMatchObject({ + status: 503, + json: { + code: 'CODING_RUNTIME_UNAVAILABLE', + error: 'The local coding runtime is unavailable', + }, + }); + expect(JSON.stringify(response.json)).not.toContain('secret'); + expect(JSON.stringify(response.json)).not.toContain(result.root); + }); + + it('retains uncertain acceptance and never resends the same request id', async () => { + class UncertainRuntime extends InMemoryConversationRuntime { + calls = 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, + ); + } + } + const runtime = new UncertainRuntime(); + const result = await setup(runtime); + const conversation = await createConversation(result.conversations); + const input = { + conversationId: conversation.id, + clientRequestId: 'request-uncertain', + mode: 'prompt', + text: 'Do not resend', + attachments: [], + }; + await expect(result.conversations.acceptPrompt(input)).rejects.toMatchObject({ + code: 'CODING_REQUEST_UNCERTAIN', + }); + await expect(result.conversations.acceptPrompt(input)).rejects.toMatchObject({ + code: 'CODING_REQUEST_UNCERTAIN', + }); + expect(runtime.calls).toBe(1); + }); +}); diff --git a/tests/unit/coding-product-services.test.ts b/tests/unit/coding-product-services.test.ts index 1a44074..5e0ab06 100644 --- a/tests/unit/coding-product-services.test.ts +++ b/tests/unit/coding-product-services.test.ts @@ -10,6 +10,7 @@ import type { AgentBrowserModule } from '../../electron/agent-browser'; import { CodingProductHostError, createCodingProductHost, + type CodingProductHostOptions, } from '../../electron/api/coding-product-services'; import { CodingAttachmentStore } from '../../electron/coding-projects/attachment-store'; import { createCodingConversationStore } from '../../electron/coding-projects/conversation-store'; @@ -65,6 +66,35 @@ function productTools(root: string): PiProductTools { }); } +function projectService( + root: string, + active = true, +): CodingProductHostOptions['projects'] { + const project = { + id: 'project-a', + path: root, + name: 'project-a', + createdAt: '2026-08-23T00:00:00.000Z', + updatedAt: '2026-08-23T00:00:00.000Z', + lastOpenedAt: '2026-08-23T00:00:00.000Z', + }; + return { + getActiveProject: async () => active ? project : null, + findActiveConversation: async (id) => { + if (!active) throw new Error('No active project'); + const conversation = await createCodingConversationStore(root).get(id); + if (!conversation) { + throw new CodingProductHostError( + 404, + 'CODING_CONVERSATION_NOT_FOUND', + 'Coding Conversation does not exist', + ); + } + return { project, conversation }; + }, + }; +} + async function git(root: string, ...args: string[]): Promise { await exec('git', ['-C', root, ...args], { windowsHide: true }); } @@ -78,7 +108,7 @@ describe('PI-105 product Host composition', () => { const root = await configuredProject(); const tools = productTools(root); const host = createCodingProductHost({ - getActiveProject: async () => ({ id: 'project-a', path: root }), + projects: projectService(root), productTools: tools, listPiCommands: async () => ({ commands: [ @@ -113,7 +143,7 @@ describe('PI-105 product Host composition', () => { await writeFile(path.join(root, 'notes.txt'), 'baseline\n', 'utf8'); const tools = productTools(root); const host = createCodingProductHost({ - getActiveProject: async () => ({ id: 'project-a', path: root }), + projects: projectService(root), productTools: tools, }); await tools.beginRun({ conversationId, runId: 'run-a', projectPath: root }); @@ -145,7 +175,7 @@ describe('PI-105 product Host composition', () => { const tools = productTools(root); const host = createCodingProductHost({ - getActiveProject: async () => ({ id: 'project-a', path: root }), + projects: projectService(root), productTools: tools, }); await tools.beginRun({ conversationId, runId: 'run-conflict', projectPath: root }); @@ -165,7 +195,7 @@ describe('PI-105 product Host composition', () => { const root = await configuredProject(); const tools = productTools(root); const unavailable = createCodingProductHost({ - getActiveProject: async () => null, + projects: projectService(root, false), productTools: tools, }); await expect(unavailable.fileStatus()).rejects.toMatchObject({ @@ -173,7 +203,7 @@ describe('PI-105 product Host composition', () => { }); const host = createCodingProductHost({ - getActiveProject: async () => ({ id: 'project-a', path: root }), + projects: projectService(root), productTools: tools, }); await expect(host.listSkills('missing')).rejects.toMatchObject({ diff --git a/tests/unit/pi-conversation-runtime.test.ts b/tests/unit/pi-conversation-runtime.test.ts index 38ffed1..da77fec 100644 --- a/tests/unit/pi-conversation-runtime.test.ts +++ b/tests/unit/pi-conversation-runtime.test.ts @@ -39,6 +39,7 @@ class RuntimeFakeWorker implements PiConversationWorker { contextUsage: { tokens: 0, contextWindow: 100_000, percent: 0 }, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; + private commandsData: unknown = { commands: [] }; private failType: string | null = null; private readonly responseGates = new Map>(); private readonly events = new Set<(event: PiRpcEvent) => void>(); @@ -69,7 +70,11 @@ class RuntimeFakeWorker implements PiConversationWorker { ? this.stateData : command.type === 'get_entries' ? this.entriesData - : command.type === 'get_session_stats' ? this.statsData : undefined; + : command.type === 'get_session_stats' + ? this.statsData + : command.type === 'get_commands' + ? this.commandsData + : undefined; return { type: 'response', id: `${this.id}-${this.requests.length}`, @@ -82,10 +87,11 @@ class RuntimeFakeWorker implements PiConversationWorker { this.requests.push(structuredClone(command)); } - setSessionData(input: { state?: unknown; entries?: unknown; stats?: unknown }): void { + setSessionData(input: { state?: unknown; entries?: unknown; stats?: unknown; commands?: unknown }): void { if (input.state !== undefined) this.stateData = structuredClone(input.state); if (input.entries !== undefined) this.entriesData = structuredClone(input.entries); if (input.stats !== undefined) this.statsData = structuredClone(input.stats); + if (input.commands !== undefined) this.commandsData = structuredClone(input.commands); } failNext(type: string): void { this.failType = type; } @@ -222,6 +228,16 @@ describe('Pi Conversation runtime', () => { }, }); await Promise.all(inputs.slice(0, 2).map((input) => runtime.prepare(input))); + workers.get(left.id)!.setSessionData({ + commands: { + commands: [{ name: 'custom', description: 'Custom command', provider: 'raw-provider' }], + apiKey: 'raw-secret', + }, + }); + expect(await runtime.listCommands(left.id)).toEqual([ + { name: 'custom', description: 'Custom command' }, + ]); + expect(await runtime.listCommands('00000000-0000-4000-8000-000000000000')).toEqual([]); const leftGenerationOneSeqs: number[] = []; const unsubscribe = runtime.subscribe((envelope) => { if (envelope.conversationId === left.id && envelope.workerGeneration === 1) { @@ -242,7 +258,7 @@ describe('Pi Conversation runtime', () => { return value; }); await expect.poll(() => workers.get(left.id)!.requests.at(-1)?.type).toBe('prompt'); - expect(workers.get(left.id)!.requests).toHaveLength(4); + expect(workers.get(left.id)!.requests).toHaveLength(5); expect(acceptanceResolved).toBe(false); releasePromptAcceptance(); const accepted = await acceptance;