feat(coding): add core host api composition
This commit is contained in:
@@ -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<ConversationRuntimeState>;
|
||||
getSnapshot(conversationId: string): Promise<ConversationSnapshot>;
|
||||
@@ -397,5 +421,14 @@ export interface CodingConversationRuntime {
|
||||
fork(input: ForkConversationInput): Promise<ForkResult>;
|
||||
recover(conversationId: string): Promise<ConversationRuntimeState>;
|
||||
dispose(conversationId: string): Promise<void>;
|
||||
listCommands(conversationId: string): Promise<CodingRuntimeCommand[]>;
|
||||
listInteractions(conversationId?: string): Promise<ConversationInteraction[]>;
|
||||
respondInteraction(
|
||||
conversationId: string,
|
||||
response: ConversationInteractionResponse,
|
||||
): Promise<void>;
|
||||
getDiagnostics(): CodingRuntimeDiagnostics;
|
||||
markProviderStale(): void;
|
||||
markResourcesStale(): void;
|
||||
subscribe(listener: (patch: ConversationPatchEnvelope) => void): () => void;
|
||||
}
|
||||
|
||||
503
electron/coding-runtime/conversation-service.ts
Normal file
503
electron/coding-runtime/conversation-service.ts
Normal file
@@ -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<CodingPromptAcceptance>;
|
||||
}
|
||||
|
||||
export type CodingConversationStreamEvent =
|
||||
| {
|
||||
type: 'snapshot';
|
||||
conversationId: string;
|
||||
workerGeneration: number;
|
||||
seq: number;
|
||||
snapshot: ConversationSnapshot;
|
||||
}
|
||||
| ({ type: 'patch' } & ConversationPatchEnvelope);
|
||||
|
||||
export interface CodingConversationEventStream {
|
||||
snapshots: ConversationSnapshot[];
|
||||
events: AsyncIterable<CodingConversationStreamEvent>;
|
||||
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<CodingConversationStreamEvent> {
|
||||
private readonly values: CodingConversationStreamEvent[] = [];
|
||||
private readonly waiters: Array<(result: IteratorResult<CodingConversationStreamEvent>) => 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<CodingConversationStreamEvent> {
|
||||
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<IteratorResult<CodingConversationStreamEvent>>((resolve) => {
|
||||
this.waiters.push(resolve);
|
||||
});
|
||||
},
|
||||
return: async () => {
|
||||
this.close();
|
||||
return { value: undefined, done: true };
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class CodingConversationService {
|
||||
private readonly prepareFlights = new Map<string, Promise<PrepareConversationInput>>();
|
||||
private readonly acceptances = new Map<string, AcceptanceRecord>();
|
||||
|
||||
constructor(
|
||||
readonly projects: CodingProjectService,
|
||||
readonly runtime: CodingConversationRuntime,
|
||||
) {}
|
||||
|
||||
async listConversations(projectId?: string): Promise<CodingConversationV2[]> {
|
||||
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<CodingConversationV2> {
|
||||
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<CodingConversationV2> {
|
||||
const { conversation } = await this.projects.findActiveConversation(conversationId);
|
||||
return publicConversation(conversation);
|
||||
}
|
||||
|
||||
async patchConversation(conversationId: string, patch: {
|
||||
title?: unknown;
|
||||
archived?: unknown;
|
||||
unread?: unknown;
|
||||
}): Promise<CodingConversationV2> {
|
||||
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<void> {
|
||||
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<ConversationSnapshot> {
|
||||
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<CodingPromptAcceptance> {
|
||||
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<CodingPromptAcceptance> => {
|
||||
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<void> {
|
||||
await this.ensurePrepared(conversationId);
|
||||
try { await this.runtime.abort(conversationId); } catch (error) { runtimeError(error); }
|
||||
}
|
||||
|
||||
async setModel(conversationId: string, model: ProductModelRef): Promise<ConversationModelState> {
|
||||
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<ConversationModelState> {
|
||||
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<void> {
|
||||
await this.ensurePrepared(conversationId);
|
||||
try { await this.runtime.compact(conversationId); } catch (error) { runtimeError(error); }
|
||||
}
|
||||
|
||||
async recover(conversationId: string): Promise<void> {
|
||||
await this.ensurePrepared(conversationId);
|
||||
try { await this.runtime.recover(conversationId); } catch (error) { runtimeError(error); }
|
||||
}
|
||||
|
||||
async fork(sourceConversationId: string, sourceEntryId?: string): Promise<CodingConversationV2> {
|
||||
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<ConversationInteraction[]> {
|
||||
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<void> {
|
||||
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<CodingRuntimeCommand[]> {
|
||||
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<CodingConversationEventStream> {
|
||||
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<string, ConversationSnapshot['cursor']>();
|
||||
let initialize!: () => void;
|
||||
const initialized = new Promise<void>((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<PrepareConversationInput> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, PromptAcceptance>();
|
||||
private readonly queueAcceptances = new Map<string, QueueAcceptance>();
|
||||
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<PromptAcceptance> {
|
||||
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<void> {
|
||||
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<CodingRuntimeCommand[]> {
|
||||
return this.states.has(conversationId) ? clone(this.commands) : [];
|
||||
}
|
||||
|
||||
async listInteractions(conversationId?: string): Promise<ConversationInteraction[]> {
|
||||
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<void> {
|
||||
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);
|
||||
|
||||
@@ -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<ConversationInteraction> {
|
||||
async respond(conversationId: string, response: ConversationInteractionResponse): Promise<ConversationInteraction> {
|
||||
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');
|
||||
|
||||
@@ -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<CodingRuntimeCommand[]> {
|
||||
const state = this.pool.getState(conversationId);
|
||||
if (!state || state.state === 'spawning' || state.state === 'crashed') return [];
|
||||
const response = await this.pool.request<unknown>(
|
||||
conversationId,
|
||||
{ type: 'get_commands' },
|
||||
{ retry: 'read-only-once' },
|
||||
);
|
||||
const record = response.data && typeof response.data === 'object' && !Array.isArray(response.data)
|
||||
? response.data as Record<string, unknown>
|
||||
: 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<string, unknown>;
|
||||
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<ConversationInteraction[]> {
|
||||
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<void> {
|
||||
await this.interactions.respond(conversationId, response);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<boolean> {
|
||||
while (true) {
|
||||
if (signal?.aborted) throw new Error('Pi idle worker reclaim cancelled');
|
||||
|
||||
Reference in New Issue
Block a user