import type { CodingConversationPatchBatchEvent, 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 | 500 | 503, readonly code: string, message: string, ) { super(message); this.name = 'CodingConversationServiceError'; } } export type CodingPromptAcceptance = PromptAcceptance; interface AcceptanceRecord { fingerprint: string; flight: Promise; state: { value: 'protected' | 'settled' }; } export interface CodingConversationServiceOptions { archiveSession?(input: { projectId: string; sessionKey: string; }): Promise; deliveryBatchWindowMs?: number; deliveryBatchMaxItems?: number; deliveryBatchMaxBytes?: number; } export type CodingConversationStreamEvent = | { type: 'snapshot'; conversationId: string; workerGeneration: number; seq: number; snapshot: ConversationSnapshot; } | CodingConversationPatchBatchEvent; 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') { const status = publicError.code === 'CODING_CONVERSATION_NOT_FOUND' ? 404 : publicError.code === 'CODING_STORAGE_WRITE_FAILED' ? 500 : publicError.code === 'CODING_RUNTIME_START_FAILED' || publicError.code === 'CODING_RUNTIME_READY_TIMEOUT' || publicError.code === 'CODING_RUNTIME_PROTOCOL_ERROR' ? 503 : 409; throw new CodingConversationServiceError( status, 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'); } async function persist(operation: () => Promise): Promise { try { return await operation(); } catch (error) { if (error instanceof CodingConversationServiceError || error instanceof CodingProjectServiceError) { throw error; } throw new CodingConversationServiceError( 500, 'CODING_STORAGE_WRITE_FAILED', 'Coding data could not be persisted', ); } } function publicConversation(conversation: CodingConversationV2): CodingConversationV2 { const { piSessionId: _piSessionId, sessionKey: _sessionKey, ...safe } = conversation; return safe; } 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 }; }, }; } } interface PendingPatchBatch { conversationId: string; workerGeneration: number; items: CodingConversationPatchBatchEvent['items']; byteLength: number; timer: ReturnType; } class PatchBatchScheduler { private readonly pending = new Map(); private readonly windowMs: number; private readonly maxItems: number; private readonly maxBytes: number; private closed = false; constructor( private readonly publish: (event: CodingConversationPatchBatchEvent) => void, options: CodingConversationServiceOptions, ) { this.windowMs = Math.min(33, Math.max(16, options.deliveryBatchWindowMs ?? 24)); this.maxItems = Math.max(1, options.deliveryBatchMaxItems ?? 128); this.maxBytes = Math.max(1, options.deliveryBatchMaxBytes ?? 256 * 1024); } push(envelope: ConversationPatchEnvelope): void { if (this.closed) return; const key = this.key(envelope.conversationId, envelope.workerGeneration); const item = { ...(envelope.runId ? { runId: envelope.runId } : {}), seq: envelope.seq, at: envelope.at, patch: envelope.patch, }; const itemBytes = Buffer.byteLength(JSON.stringify(item), 'utf8'); let batch = this.pending.get(key); if (batch && batch.items.length > 0 && (batch.items.length >= this.maxItems || batch.byteLength + itemBytes > this.maxBytes)) { this.flush(key); batch = undefined; } if (!batch) { batch = { conversationId: envelope.conversationId, workerGeneration: envelope.workerGeneration, items: [], byteLength: 0, timer: setTimeout(() => this.flush(key), this.windowMs), }; this.pending.set(key, batch); } batch.items.push(item); batch.byteLength += itemBytes; if (batch.items.length >= this.maxItems || batch.byteLength >= this.maxBytes) { this.flush(key); } } cancel(conversationId: string): void { for (const [key, batch] of this.pending) { if (batch.conversationId !== conversationId) continue; clearTimeout(batch.timer); this.pending.delete(key); } } close(): void { this.closed = true; for (const batch of this.pending.values()) clearTimeout(batch.timer); this.pending.clear(); } private key(conversationId: string, workerGeneration: number): string { return `${conversationId}\u0000${workerGeneration}`; } private flush(key: string): void { const batch = this.pending.get(key); if (!batch || this.closed) return; this.pending.delete(key); clearTimeout(batch.timer); const first = batch.items[0]; const last = batch.items.at(-1); if (!first || !last) return; this.publish({ type: 'patch-batch', conversationId: batch.conversationId, workerGeneration: batch.workerGeneration, fromSeq: first.seq, toSeq: last.seq, items: batch.items, }); } } export class CodingConversationService { private readonly prepareFlights = new Map>(); private readonly acceptances = new Map(); constructor( readonly projects: CodingProjectService, readonly runtime: CodingConversationRuntime, private readonly options: CodingConversationServiceOptions = {}, ) {} 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 persist(() => this.projects.conversationStore(project.path).create({ agentId, title: requiredString(input.title, 'Conversation title', MAX_TITLE_LENGTH), model: agent.model, modelResolution: agent.modelResolution, })); return publicConversation(created); } 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 persist(() => 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, conversation } = await this.projects.findActiveConversation(conversationId); try { await this.runtime.dispose(conversationId); } catch (error) { runtimeError(error); } await this.archiveSession(project.id, conversation.sessionKey); await persist(() => this.projects.conversationStore(project.path).delete(conversationId)); this.prepareFlights.delete(conversationId); for (const key of [...this.acceptances.keys()]) { if (key.startsWith(`${conversationId}\u0000`)) this.acceptances.delete(key); } } 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'); } if (prior.state.value === 'settled') { this.acceptances.delete(key); this.acceptances.set(key, prior); } return await prior.flight; } this.reserveAcceptanceSlot(); const acceptanceState: AcceptanceRecord['state'] = { value: 'protected' }; const flight = (async (): Promise => { try { await this.ensurePrepared(conversationId); const acceptance = await this.runtime.prompt({ conversationId, clientRequestId, mode, text: input.text as string, attachments: normalizedAttachments, }); acceptanceState.value = 'settled'; return acceptance; } catch (error) { const publicCode = error && typeof error === 'object' && 'publicError' in error && 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, state: acceptanceState }); 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 { const { project, conversation } = await this.projects.findActiveConversation(conversationId); let selected: ProductModelRef; try { selected = await this.runtime.validateModel(model); } catch (error) { runtimeError(error); } if (conversation.modelResolution === 'resolved' && conversation.model) { await this.ensurePrepared(conversationId); let state: ConversationModelState; try { state = await this.runtime.setModel({ conversationId, accountId: selected.accountId, modelId: selected.modelId, }); } catch (error) { runtimeError(error); } await persist(() => this.projects.conversationStore(project.path).setModelState(conversationId, state)); return state; } const state: ConversationModelState = { model: selected, modelResolution: 'resolved', }; await persist(() => this.projects.conversationStore(project.path).setModelState(conversationId, state)); const preparing = this.prepareFlights.get(conversationId); if (preparing) await preparing.catch(() => undefined); try { await this.runtime.dispose(conversationId); this.prepareFlights.delete(conversationId); await this.ensurePrepared(conversationId); return state; } catch (error) { runtimeError(error); } } 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 persist(() => 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 entryId = requiredString(sourceEntryId, 'Fork source entry id', 256); const prepared = await this.ensurePrepared(sourceConversationId); let sourceSnapshot: ConversationSnapshot; try { sourceSnapshot = await this.runtime.getSnapshot(sourceConversationId); } catch (error) { runtimeError(error); } const sourceNode = sourceSnapshot.nodes.find((node) => ( node.kind === 'message' && node.sourceEntryId === entryId && node.role === 'user' && node.status !== 'optimistic' )); if (!sourceNode) { throw new CodingConversationServiceError( 400, 'CODING_CONVERSATION_REQUEST_INVALID', 'Fork source must be a durable user message on the active Conversation path', ); } const source = await this.projects.findActiveConversation(sourceConversationId); const store = this.projects.conversationStore(source.project.path); const created = await persist(() => store.create({ agentId: source.conversation.agentId, title: `${source.conversation.title} (fork)`, model: source.conversation.model, modelResolution: source.conversation.modelResolution, })); try { await this.runtime.fork({ sourceConversationId, sourceEntryId: entryId, conversation: { ...prepared, conversationId: created.id, title: created.title, model: { model: created.model, modelResolution: created.modelResolution, }, }, }); return publicConversation(created); } catch (error) { try { await this.runtime.dispose(created.id); const latest = await store.get(created.id); await this.archiveSession(source.project.id, latest?.sessionKey); await persist(() => store.delete(created.id)); } catch { // Keep product metadata when cleanup cannot complete so the partial // runtime/session remains owned and can be diagnosed or deleted later. } runtimeError(error); } } 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 batches = new PatchBatchScheduler((event) => queue.push(event), this.options); 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) { batches.cancel(event.conversationId); 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, }); batches.push(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(); batches.close(); queue.close(); }, }; } catch (error) { initialize(); unsubscribe(); batches.close(); 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 reserveAcceptanceSlot(): void { while (this.acceptances.size >= MAX_ACCEPTANCES) { const settled = [...this.acceptances.entries()] .find(([, record]) => record.state.value === 'settled'); if (!settled) { throw new CodingConversationServiceError( 503, 'CODING_REQUEST_CAPACITY_EXCEEDED', 'The local request registry is full', ); } this.acceptances.delete(settled[0]); } } private async archiveSession(projectId: string, sessionKey: string | undefined): Promise { if (!sessionKey) return; if (!this.options.archiveSession) { throw new CodingConversationServiceError( 500, 'CODING_STORAGE_WRITE_FAILED', 'Coding session archival is unavailable', ); } await persist(() => this.options.archiveSession!({ projectId, sessionKey })); } }