From 92713a37048a248cb9748e4206ce5c67f19c707a Mon Sep 17 00:00:00 2001 From: sherlock Date: Mon, 16 Mar 2026 09:11:26 +0530 Subject: [PATCH 1/5] feat: bring OpenCode adapter to feature parity with other adapters - Emit session.configured after session creation and session.exited on stop/stream death for lifecycle parity with Claude Code and Copilot - Forward session.diff as turn.diff.updated with unified diff conversion - Handle 6 new SSE event types: session.compacted, session.updated, vcs.branch.updated, file.edited, command.executed, message.part.removed - Enrich existing events: todo priority prefixes, isRetryable on API errors, permission title/pattern metadata, question multiple/custom fields - Implement rollbackThread via session.revert API with message ID tracking - Extract opencode/ module files from opencodeServerManager.ts - Fix flaky OpenCodeAdapter stream forwarding test (race condition) - Improve runtime error display with error class labels --- apps/server/src/opencode/errors.ts | 111 ++ apps/server/src/opencode/eventHandlers.ts | 856 +++++++++ apps/server/src/opencode/index.ts | 5 + apps/server/src/opencode/serverLifecycle.ts | 165 ++ apps/server/src/opencode/types.ts | 596 +++++++ apps/server/src/opencode/utils.ts | 381 ++++ apps/server/src/opencodeServerManager.ts | 1562 +---------------- .../Layers/ProviderRuntimeIngestion.ts | 21 +- .../provider/Layers/OpenCodeAdapter.test.ts | 10 +- 9 files changed, 2238 insertions(+), 1469 deletions(-) create mode 100644 apps/server/src/opencode/errors.ts create mode 100644 apps/server/src/opencode/eventHandlers.ts create mode 100644 apps/server/src/opencode/index.ts create mode 100644 apps/server/src/opencode/serverLifecycle.ts create mode 100644 apps/server/src/opencode/types.ts create mode 100644 apps/server/src/opencode/utils.ts diff --git a/apps/server/src/opencode/errors.ts b/apps/server/src/opencode/errors.ts new file mode 100644 index 0000000000..8837647199 --- /dev/null +++ b/apps/server/src/opencode/errors.ts @@ -0,0 +1,111 @@ +import type { EventSessionError } from "./types.ts"; + +/** + * Maps an OpenCode error name to a runtime error class used by the + * orchestration layer to categorize errors for display. + */ +export function sessionErrorClass( + errorName: string | undefined, +): "provider_error" | "transport_error" | "permission_error" | "validation_error" | "unknown" { + switch (errorName) { + case "ProviderAuthError": + return "permission_error"; + case "APIError": + case "ContextOverflowError": + case "MessageOutputLengthError": + case "StructuredOutputError": + return "provider_error"; + case "MessageAbortedError": + return "transport_error"; + case "UnknownError": + default: + return "unknown"; + } +} + +/** + * Returns a human-readable label for the OpenCode error name. + */ +export function sessionErrorLabel(errorName: string): string { + switch (errorName) { + case "ProviderAuthError": + return "Authentication failed"; + case "UnknownError": + return "Unknown error"; + case "MessageAbortedError": + return "Message aborted"; + case "StructuredOutputError": + return "Structured output error"; + case "ContextOverflowError": + return "Context window exceeded"; + case "APIError": + return "API error"; + case "MessageOutputLengthError": + return "Response exceeded output length"; + default: + return errorName; + } +} + +/** + * Returns whether an OpenCode error is retryable, if the information is + * available (currently only `APIError` carries `isRetryable`). + */ +export function sessionErrorIsRetryable( + error: EventSessionError["properties"]["error"], +): boolean | undefined { + if (!error) { + return undefined; + } + if (error.name === "APIError") { + const data = error.data as Record | undefined; + return typeof data?.isRetryable === "boolean" ? data.isRetryable : undefined; + } + return undefined; +} + +/** + * Extracts a human-readable error message from an OpenCode `session.error` + * event, combining the error label with any detail from the payload. + * + * Each OpenCode error type has a specific `data` shape (from the SDK): + * - ProviderAuthError: { providerID, message } + * - UnknownError: { message } + * - MessageAbortedError: { message } + * - StructuredOutputError: { message, retries } + * - ContextOverflowError: { message, responseBody? } + * - APIError: { message, statusCode?, isRetryable, responseHeaders?, responseBody?, metadata? } + * - MessageOutputLengthError: { [key: string]: unknown } + */ +export function sessionErrorMessage( + error: EventSessionError["properties"]["error"], +): string | undefined { + if (!error) { + return undefined; + } + + const data = error.data as Record | undefined; + const label = sessionErrorLabel(error.name); + const detail = typeof data?.message === "string" ? data.message : undefined; + + switch (error.name) { + case "ProviderAuthError": { + const providerID = typeof data?.providerID === "string" ? data.providerID : undefined; + const prefix = providerID ? `${label} (${providerID})` : label; + return detail ? `${prefix}: ${detail}` : prefix; + } + case "APIError": { + const statusCode = typeof data?.statusCode === "number" ? data.statusCode : undefined; + const prefix = statusCode ? `${label} ${statusCode}` : label; + return detail ? `${prefix}: ${detail}` : prefix; + } + case "StructuredOutputError": { + const retries = typeof data?.retries === "number" ? data.retries : undefined; + const suffix = retries != null ? ` (after ${retries} retries)` : ""; + return detail ? `${label}: ${detail}${suffix}` : `${label}${suffix}`; + } + default: { + return detail ? `${label}: ${detail}` : label; + } + } +} diff --git a/apps/server/src/opencode/eventHandlers.ts b/apps/server/src/opencode/eventHandlers.ts new file mode 100644 index 0000000000..b4fc5b69b5 --- /dev/null +++ b/apps/server/src/opencode/eventHandlers.ts @@ -0,0 +1,856 @@ +import { ApprovalRequestId, RuntimeItemId, RuntimeRequestId } from "@t3tools/contracts"; + +import { sessionErrorClass, sessionErrorIsRetryable, sessionErrorMessage } from "./errors.ts"; +import type { + EventCommandExecuted, + EventFileEdited, + EventMessagePartDelta, + EventMessagePartUpdated, + EventPermissionAsked, + EventPermissionReplied, + EventQuestionAsked, + EventQuestionRejected, + EventQuestionReplied, + EventSessionCompacted, + EventSessionDiff, + EventSessionError, + EventSessionIdle, + EventSessionStatus, + EventSessionUpdated, + EventTodoUpdated, + EventVcsBranchUpdated, + OpenCodeEvent, + OpenCodeProviderRuntimeEvent, + OpenCodeSessionContext, + OpenCodeToolPart, + QuestionInfo, +} from "./types.ts"; +import { PROVIDER } from "./types.ts"; +import { + eventId, + fileDiffsToUnifiedDiff, + nowIso, + stripTransientSessionFields, + todoPriorityPrefix, + toOpencodeRequestType, + toPlanStepStatus, + toToolItemType, + toToolLifecycleEventType, + toToolTitle, + toolStateDetail, + toolStateTitle, +} from "./utils.ts"; + +type EventEmitter = { + emitRuntimeEvent(event: OpenCodeProviderRuntimeEvent): void; +}; + +/** + * Dispatches an OpenCode SSE event to the appropriate handler. + */ +export function handleEvent( + emitter: EventEmitter, + context: OpenCodeSessionContext, + event: OpenCodeEvent, +): void { + switch (event.type) { + case "session.status": + handleSessionStatusEvent(emitter, context, event); + return; + case "session.idle": + handleSessionIdleEvent(emitter, context, event); + return; + case "session.diff": + handleSessionDiffEvent(emitter, context, event); + return; + case "session.error": + handleSessionErrorEvent(emitter, context, event); + return; + case "session.compacted": + handleSessionCompactedEvent(emitter, context, event); + return; + case "session.updated": + handleSessionUpdatedEvent(emitter, context, event); + return; + case "permission.asked": + handlePermissionAskedEvent(emitter, context, event); + return; + case "permission.replied": + handlePermissionRepliedEvent(emitter, context, event); + return; + case "question.asked": + handleQuestionAskedEvent(emitter, context, event); + return; + case "question.replied": + handleQuestionRepliedEvent(emitter, context, event); + return; + case "question.rejected": + handleQuestionRejectedEvent(emitter, context, event); + return; + case "message.part.updated": + handleMessagePartUpdatedEvent(emitter, context, event); + return; + case "message.part.delta": + handleMessagePartDeltaEvent(emitter, context, event); + return; + case "message.part.removed": + // Silently ignored — prevents "unknown event" issues if logging is added later. + return; + case "todo.updated": + handleTodoUpdatedEvent(emitter, context, event); + return; + case "vcs.branch.updated": + handleVcsBranchUpdatedEvent(emitter, context, event); + return; + case "file.edited": + handleFileEditedEvent(emitter, context, event); + return; + case "command.executed": + handleCommandExecutedEvent(emitter, context, event); + return; + } +} + +// --------------------------------------------------------------------------- +// Session status / lifecycle +// --------------------------------------------------------------------------- + +function handleSessionStatusEvent( + emitter: EventEmitter, + context: OpenCodeSessionContext, + event: EventSessionStatus, +): void { + const { sessionID: sessionId, status } = event.properties; + if (sessionId !== context.providerSessionId) { + return; + } + const statusType = status.type; + + if (statusType === "busy") { + context.session = { + ...context.session, + status: "running", + updatedAt: nowIso(), + }; + emitter.emitRuntimeEvent({ + type: "session.state.changed", + eventId: eventId("opencode-status-busy"), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), + payload: { + state: "running", + }, + raw: { + source: "opencode.server.event", + messageType: statusType, + payload: event, + }, + }); + return; + } + + if (statusType === "retry") { + emitter.emitRuntimeEvent({ + type: "session.state.changed", + eventId: eventId("opencode-status-retry"), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), + payload: { + state: "waiting", + reason: "retry", + detail: event, + }, + raw: { + source: "opencode.server.event", + messageType: statusType, + payload: event, + }, + }); + return; + } + + if (statusType === "idle") { + completeTurn(emitter, context, "opencode-status-idle", "opencode-turn-completed", event); + } +} + +function handleSessionIdleEvent( + emitter: EventEmitter, + context: OpenCodeSessionContext, + event: EventSessionIdle, +): void { + const { sessionID: sessionId } = event.properties; + if (sessionId !== context.providerSessionId) { + return; + } + completeTurn(emitter, context, "opencode-session-idle", "opencode-turn-completed-idle", event); +} + +/** + * Shared logic for completing a turn when session goes idle (via either + * `session.status` with type=idle or the dedicated `session.idle` event). + */ +function completeTurn( + emitter: EventEmitter, + context: OpenCodeSessionContext, + stateEventPrefix: string, + turnEventPrefix: string, + event: EventSessionStatus | EventSessionIdle, +): void { + const completedAt = nowIso(); + const turnId = context.activeTurnId; + const lastError = context.lastError; + context.activeTurnId = undefined; + context.lastError = undefined; + context.session = { + ...stripTransientSessionFields(context.session), + status: lastError ? "error" : "ready", + updatedAt: completedAt, + ...(lastError ? { lastError } : {}), + }; + + const messageType = + event.type === "session.idle" + ? "session.idle" + : (event as EventSessionStatus).properties.status.type; + + emitter.emitRuntimeEvent({ + type: "session.state.changed", + eventId: eventId(stateEventPrefix), + provider: PROVIDER, + threadId: context.threadId, + createdAt: completedAt, + ...(turnId ? { turnId } : {}), + payload: { + state: lastError ? "error" : "ready", + ...(lastError ? { reason: lastError } : {}), + ...(event.type !== "session.idle" ? { detail: event } : {}), + }, + raw: { + source: "opencode.server.event", + messageType, + payload: event, + }, + }); + + if (turnId) { + emitter.emitRuntimeEvent({ + type: "turn.completed", + eventId: eventId(turnEventPrefix), + provider: PROVIDER, + threadId: context.threadId, + createdAt: completedAt, + turnId, + payload: { + state: lastError ? "failed" : "completed", + ...(lastError ? { errorMessage: lastError } : {}), + }, + raw: { + source: "opencode.server.event", + messageType, + payload: event, + }, + }); + } +} + +function handleSessionDiffEvent( + emitter: EventEmitter, + context: OpenCodeSessionContext, + event: EventSessionDiff, +): void { + const { sessionID: sessionId, diff } = event.properties; + if (sessionId !== context.providerSessionId) { + return; + } + if (!context.activeTurnId || !diff || diff.length === 0) { + return; + } + const unifiedDiff = fileDiffsToUnifiedDiff(diff); + emitter.emitRuntimeEvent({ + type: "turn.diff.updated", + eventId: eventId("opencode-turn-diff-updated"), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + turnId: context.activeTurnId, + payload: { + unifiedDiff, + }, + raw: { + source: "opencode.server.event", + messageType: "session.diff", + payload: event, + }, + }); +} + +function handleSessionErrorEvent( + emitter: EventEmitter, + context: OpenCodeSessionContext, + event: EventSessionError, +): void { + const { sessionID: sessionId, error } = event.properties; + if (sessionId && sessionId !== context.providerSessionId) { + return; + } + const errorMessage = sessionErrorMessage(error) ?? "OpenCode session error"; + const errorClass = sessionErrorClass(error?.name); + const isRetryable = sessionErrorIsRetryable(error); + context.lastError = errorMessage; + context.session = { + ...stripTransientSessionFields(context.session), + status: "error", + updatedAt: nowIso(), + lastError: errorMessage, + }; + emitter.emitRuntimeEvent({ + type: "runtime.error", + eventId: eventId("opencode-session-error"), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), + payload: { + message: errorMessage, + class: errorClass, + ...(isRetryable != null ? { detail: { isRetryable } } : {}), + }, + raw: { + source: "opencode.server.event", + messageType: "session.error", + payload: event, + }, + }); +} + +// --------------------------------------------------------------------------- +// Permission events +// --------------------------------------------------------------------------- + +function handlePermissionAskedEvent( + emitter: EventEmitter, + context: OpenCodeSessionContext, + event: EventPermissionAsked, +): void { + const { id: requestIdValue, sessionID: sessionId, permission, title } = event.properties; + if (sessionId !== context.providerSessionId) { + return; + } + const requestType = toOpencodeRequestType(permission); + const requestId = ApprovalRequestId.makeUnsafe(requestIdValue); + context.pendingPermissions.set(requestId, { requestId, requestType }); + emitter.emitRuntimeEvent({ + type: "request.opened", + eventId: eventId("opencode-request-opened"), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), + requestId: RuntimeRequestId.makeUnsafe(requestId), + payload: { + requestType, + detail: title ?? permission, + args: event.properties, + }, + raw: { + source: "opencode.server.permission", + messageType: "permission.asked", + payload: event, + }, + }); +} + +function handlePermissionRepliedEvent( + emitter: EventEmitter, + context: OpenCodeSessionContext, + event: EventPermissionReplied, +): void { + const { requestID: requestIdValue, sessionID: sessionId, reply } = event.properties; + if (sessionId !== context.providerSessionId) { + return; + } + const pending = context.pendingPermissions.get(requestIdValue); + context.pendingPermissions.delete(requestIdValue); + emitter.emitRuntimeEvent({ + type: "request.resolved", + eventId: eventId("opencode-request-resolved"), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), + requestId: RuntimeRequestId.makeUnsafe(requestIdValue), + payload: { + requestType: pending?.requestType ?? "unknown", + decision: reply, + resolution: event.properties, + }, + raw: { + source: "opencode.server.permission", + messageType: "permission.replied", + payload: event, + }, + }); +} + +// --------------------------------------------------------------------------- +// Question events +// --------------------------------------------------------------------------- + +function handleQuestionAskedEvent( + emitter: EventEmitter, + context: OpenCodeSessionContext, + event: EventQuestionAsked, +): void { + const { + id: requestIdValue, + sessionID: sessionId, + questions: askedQuestions, + } = event.properties; + if (sessionId !== context.providerSessionId) { + return; + } + const questions = askedQuestions.map((question: QuestionInfo, index) => ({ + answerIndex: index, + id: `${requestIdValue}:${index}`, + header: question.header, + question: question.question, + options: question.options.map((option) => ({ + label: option.label, + description: option.description, + })), + })); + const runtimeQuestions = questions.map((question) => ({ + id: question.id, + header: question.header, + question: question.question, + options: question.options, + })); + + const requestId = ApprovalRequestId.makeUnsafe(requestIdValue); + context.pendingQuestions.set(requestId, { + requestId, + questionIds: questions.map((question) => question.id), + questions, + }); + emitter.emitRuntimeEvent({ + type: "user-input.requested", + eventId: eventId("opencode-user-input-requested"), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), + requestId: RuntimeRequestId.makeUnsafe(requestId), + payload: { + questions: runtimeQuestions, + }, + raw: { + source: "opencode.server.question", + messageType: "question.asked", + payload: event, + }, + }); +} + +function handleQuestionRepliedEvent( + emitter: EventEmitter, + context: OpenCodeSessionContext, + event: EventQuestionReplied, +): void { + const { + requestID: requestIdValue, + sessionID: sessionId, + answers: answerArrays, + } = event.properties; + if (sessionId !== context.providerSessionId) { + return; + } + const pending = context.pendingQuestions.get(requestIdValue); + context.pendingQuestions.delete(requestIdValue); + const answers = Object.fromEntries( + (pending?.questions ?? []).map((question) => { + const answer = answerArrays[question.answerIndex]; + if (!answer) { + return [question.id, ""]; + } + return [question.id, answer.filter((value) => value.length > 0)]; + }), + ); + emitter.emitRuntimeEvent({ + type: "user-input.resolved", + eventId: eventId("opencode-user-input-resolved"), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), + requestId: RuntimeRequestId.makeUnsafe(requestIdValue), + payload: { + answers, + }, + raw: { + source: "opencode.server.question", + messageType: "question.replied", + payload: event, + }, + }); +} + +function handleQuestionRejectedEvent( + emitter: EventEmitter, + context: OpenCodeSessionContext, + event: EventQuestionRejected, +): void { + const { requestID: requestIdValue, sessionID: sessionId } = event.properties; + if (sessionId !== context.providerSessionId) { + return; + } + context.pendingQuestions.delete(requestIdValue); + emitter.emitRuntimeEvent({ + type: "user-input.resolved", + eventId: eventId("opencode-user-input-rejected"), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), + requestId: RuntimeRequestId.makeUnsafe(requestIdValue), + payload: { + answers: {}, + }, + raw: { + source: "opencode.server.question", + messageType: "question.rejected", + payload: event, + }, + }); +} + +// --------------------------------------------------------------------------- +// Message part events (text, reasoning, tool) +// --------------------------------------------------------------------------- + +function handleMessagePartUpdatedEvent( + emitter: EventEmitter, + context: OpenCodeSessionContext, + event: EventMessagePartUpdated, +): void { + const { part } = event.properties; + if (part.sessionID !== context.providerSessionId) { + return; + } + // Track message IDs for rollback support (Tier 4a) + if (part.messageID && !context.messageIds.includes(part.messageID)) { + context.messageIds.push(part.messageID); + } + if (part.type === "text") { + context.partStreamById.set(part.id, { kind: "text", streamKind: "assistant_text" }); + return; + } + if (part.type === "reasoning") { + context.partStreamById.set(part.id, { kind: "reasoning", streamKind: "reasoning_text" }); + return; + } + + if (part.type === "tool") { + handleToolPartUpdatedEvent(emitter, context, event, part); + } +} + +function handleToolPartUpdatedEvent( + emitter: EventEmitter, + context: OpenCodeSessionContext, + event: EventMessagePartUpdated, + part: OpenCodeToolPart, +): void { + const previous = context.partStreamById.get(part.id); + const title = toolStateTitle(part.state); + const detail = toolStateDetail(part.state); + const lifecycleType = toToolLifecycleEventType(previous, part.state.status); + + context.partStreamById.set(part.id, { kind: "tool" }); + emitter.emitRuntimeEvent({ + type: lifecycleType, + eventId: eventId(`opencode-tool-${lifecycleType.replace(".", "-")}`), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), + itemId: RuntimeItemId.makeUnsafe(part.id), + payload: { + itemType: toToolItemType(part.tool), + ...(lifecycleType !== "item.updated" + ? { + status: lifecycleType === "item.completed" ? "completed" : "inProgress", + } + : {}), + title: toToolTitle(part.tool), + ...(detail ? { detail } : {}), + data: { + item: part, + }, + }, + raw: { + source: "opencode.server.event", + messageType: "message.part.updated", + payload: event, + }, + }); + + if ((part.state.status === "completed" || part.state.status === "error") && title) { + emitter.emitRuntimeEvent({ + type: "tool.summary", + eventId: eventId("opencode-tool-summary"), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), + itemId: RuntimeItemId.makeUnsafe(part.id), + payload: { + summary: `${part.tool}: ${title}`, + precedingToolUseIds: [part.id], + }, + raw: { + source: "opencode.server.event", + messageType: "message.part.updated", + payload: event, + }, + }); + } +} + +function handleMessagePartDeltaEvent( + emitter: EventEmitter, + context: OpenCodeSessionContext, + event: EventMessagePartDelta, +): void { + const { sessionID, partID: partId, delta } = event.properties; + if (sessionID !== context.providerSessionId) { + return; + } + if (!context.activeTurnId || delta.length === 0) { + return; + } + const partState = context.partStreamById.get(partId); + if (partState?.kind === "tool") { + return; + } + emitter.emitRuntimeEvent({ + type: "content.delta", + eventId: eventId("opencode-content-delta"), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + turnId: context.activeTurnId, + itemId: RuntimeItemId.makeUnsafe(partId), + payload: { + streamKind: partState?.streamKind ?? "assistant_text", + delta, + }, + raw: { + source: "opencode.server.event", + messageType: "message.part.delta", + payload: event, + }, + }); +} + +// --------------------------------------------------------------------------- +// Todo / plan events +// --------------------------------------------------------------------------- + +function handleTodoUpdatedEvent( + emitter: EventEmitter, + context: OpenCodeSessionContext, + event: EventTodoUpdated, +): void { + const { sessionID, todos } = event.properties; + if (sessionID !== context.providerSessionId || !context.activeTurnId) { + return; + } + const plan = todos.map((todo) => ({ + step: todoPriorityPrefix(todo), + status: toPlanStepStatus(todo.status), + })); + emitter.emitRuntimeEvent({ + type: "turn.plan.updated", + eventId: eventId("opencode-plan-updated"), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + turnId: context.activeTurnId, + payload: { + plan, + }, + raw: { + source: "opencode.server.event", + messageType: "todo.updated", + payload: event, + }, + }); +} + +// --------------------------------------------------------------------------- +// Tier 2 — New SSE event handlers +// --------------------------------------------------------------------------- + +function handleSessionCompactedEvent( + emitter: EventEmitter, + context: OpenCodeSessionContext, + event: EventSessionCompacted, +): void { + const { sessionID } = event.properties; + if (sessionID !== context.providerSessionId) { + return; + } + emitter.emitRuntimeEvent({ + type: "thread.state.changed", + eventId: eventId("opencode-session-compacted"), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), + payload: { + state: "compacted", + }, + raw: { + source: "opencode.server.event", + messageType: "session.compacted", + payload: event, + }, + }); +} + +function handleSessionUpdatedEvent( + emitter: EventEmitter, + context: OpenCodeSessionContext, + event: EventSessionUpdated, +): void { + const { sessionID, info } = event.properties; + if (sessionID !== context.providerSessionId) { + return; + } + emitter.emitRuntimeEvent({ + type: "thread.metadata.updated", + eventId: eventId("opencode-session-updated"), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), + payload: { + ...(info?.title ? { name: info.title } : {}), + metadata: info ?? {}, + }, + raw: { + source: "opencode.server.event", + messageType: "session.updated", + payload: event, + }, + }); +} + +function handleVcsBranchUpdatedEvent( + emitter: EventEmitter, + context: OpenCodeSessionContext, + event: EventVcsBranchUpdated, +): void { + emitter.emitRuntimeEvent({ + type: "thread.metadata.updated", + eventId: eventId("opencode-vcs-branch-updated"), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), + payload: { + metadata: { branch: event.properties.branch }, + }, + raw: { + source: "opencode.server.event", + messageType: "vcs.branch.updated", + payload: event, + }, + }); +} + +function handleFileEditedEvent( + emitter: EventEmitter, + context: OpenCodeSessionContext, + event: EventFileEdited, +): void { + emitter.emitRuntimeEvent({ + type: "files.persisted", + eventId: eventId("opencode-file-edited"), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), + payload: { + files: [ + { + filename: event.properties.filename, + fileId: event.properties.fileId ?? event.properties.filename, + }, + ], + }, + raw: { + source: "opencode.server.event", + messageType: "file.edited", + payload: event, + }, + }); +} + +function handleCommandExecutedEvent( + emitter: EventEmitter, + context: OpenCodeSessionContext, + event: EventCommandExecuted, +): void { + const { sessionID, command } = event.properties; + if (sessionID !== context.providerSessionId) { + return; + } + const itemId = RuntimeItemId.makeUnsafe(`cmd:${command}:${Date.now()}`); + const title = `Command: ${command}`; + emitter.emitRuntimeEvent({ + type: "item.started", + eventId: eventId("opencode-command-started"), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), + itemId, + payload: { + itemType: "dynamic_tool_call", + status: "inProgress", + title, + data: { item: event.properties }, + }, + raw: { + source: "opencode.server.event", + messageType: "command.executed", + payload: event, + }, + }); + emitter.emitRuntimeEvent({ + type: "item.completed", + eventId: eventId("opencode-command-completed"), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), + itemId, + payload: { + itemType: "dynamic_tool_call", + status: "completed", + title, + data: { item: event.properties }, + }, + raw: { + source: "opencode.server.event", + messageType: "command.executed", + payload: event, + }, + }); +} diff --git a/apps/server/src/opencode/index.ts b/apps/server/src/opencode/index.ts new file mode 100644 index 0000000000..6c01f8ea13 --- /dev/null +++ b/apps/server/src/opencode/index.ts @@ -0,0 +1,5 @@ +export * from "./types.ts"; +export * from "./errors.ts"; +export * from "./utils.ts"; +export * from "./eventHandlers.ts"; +export * from "./serverLifecycle.ts"; diff --git a/apps/server/src/opencode/serverLifecycle.ts b/apps/server/src/opencode/serverLifecycle.ts new file mode 100644 index 0000000000..1a05fb1f2d --- /dev/null +++ b/apps/server/src/opencode/serverLifecycle.ts @@ -0,0 +1,165 @@ +import { spawn } from "node:child_process"; + +import { + DEFAULT_HOSTNAME, + DEFAULT_PORT, + SERVER_PROBE_TIMEOUT_MS, + SERVER_START_TIMEOUT_MS, + type OpenCodeProviderOptions, + type OpencodeClient, + type OpencodeClientOptions, + type OpenCodeSdkModule, + type SharedServerState, +} from "./types.ts"; +import { buildAuthHeader, parseServerUrl } from "./utils.ts"; + +/** + * Probes the OpenCode server health endpoint to check if it's running. + */ +export async function probeServer(baseUrl: string, authHeader?: string): Promise { + const response = await fetch(`${baseUrl}/global/health`, { + method: "GET", + ...(authHeader ? { headers: { Authorization: authHeader } } : {}), + signal: AbortSignal.timeout(SERVER_PROBE_TIMEOUT_MS), + }).catch(() => undefined); + return response?.ok === true; +} + +/** + * Creates an OpenCode SDK client by dynamically importing the SDK. + */ +export async function createClient(options: OpencodeClientOptions): Promise { + const sdkModuleId = "@opencode-ai/sdk/v2/client"; + const sdk = (await import(sdkModuleId)) as OpenCodeSdkModule; + return sdk.createOpencodeClient(options); +} + +/** + * Ensures an OpenCode server is running, either by connecting to an existing + * one or spawning a new process. Returns the shared server state. + */ +export async function ensureServer( + options: OpenCodeProviderOptions | undefined, + cached: { server: SharedServerState | undefined; serverPromise: Promise | undefined }, +): Promise<{ + state: SharedServerState; + serverPromise: Promise | undefined; +}> { + if (cached.server) { + return { state: cached.server, serverPromise: cached.serverPromise }; + } + if (cached.serverPromise) { + const state = await cached.serverPromise; + return { state, serverPromise: cached.serverPromise }; + } + + const serverPromise = spawnOrConnect(options); + try { + const state = await serverPromise; + return { state, serverPromise }; + } catch { + return { state: await serverPromise, serverPromise: undefined }; + } +} + +async function spawnOrConnect(options?: OpenCodeProviderOptions): Promise { + const authHeader = buildAuthHeader(options?.username, options?.password); + + if (options?.serverUrl) { + return { + baseUrl: options.serverUrl, + ...(authHeader ? { authHeader } : {}), + }; + } + + const hostname = options?.hostname ?? DEFAULT_HOSTNAME; + const port = Math.trunc(options?.port ?? DEFAULT_PORT); + const baseUrl = `http://${hostname}:${port}`; + const healthy = await probeServer(baseUrl, authHeader); + if (healthy) { + return { + baseUrl, + ...(authHeader ? { authHeader } : {}), + }; + } + + const binaryPath = options?.binaryPath ?? "opencode"; + const child = spawn(binaryPath, ["serve", `--hostname=${hostname}`, `--port=${port}`], { + env: { + ...process.env, + ...(options?.username ? { OPENCODE_SERVER_USERNAME: options.username } : {}), + ...(options?.password ? { OPENCODE_SERVER_PASSWORD: options.password } : {}), + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + const startedBaseUrl = await new Promise((resolve, reject) => { + let output = ""; + + const onChunk = (chunk: Buffer) => { + output += chunk.toString(); + const url = parseServerUrl(output); + if (!url) { + return; + } + cleanup(); + resolve(url); + }; + + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + + const onExit = (code: number | null) => { + cleanup(); + void probeServer(baseUrl, authHeader).then((reuse) => { + if (reuse) { + resolve(baseUrl); + return; + } + const detail = output.trim().replaceAll(/\s+/g, " ").slice(0, 400); + reject( + new Error( + `OpenCode server exited before startup completed (code ${code})${ + detail.length > 0 ? `: ${detail}` : "" + }`, + ), + ); + }); + }; + + const cleanup = () => { + clearTimeout(timeout); + child.stdout.off("data", onChunk); + child.stderr.off("data", onChunk); + child.off("error", onError); + child.off("exit", onExit); + }; + + const timeout = setTimeout(() => { + cleanup(); + try { + child.kill(); + } catch { + // Process may already be dead. + } + reject( + new Error( + `Timed out waiting for OpenCode server to start after ${SERVER_START_TIMEOUT_MS}ms`, + ), + ); + }, SERVER_START_TIMEOUT_MS); + + child.stdout.on("data", onChunk); + child.stderr.on("data", onChunk); + child.once("error", onError); + child.once("exit", onExit); + }); + + return { + baseUrl: startedBaseUrl, + child, + ...(authHeader ? { authHeader } : {}), + }; +} diff --git a/apps/server/src/opencode/types.ts b/apps/server/src/opencode/types.ts new file mode 100644 index 0000000000..7bd13e97e8 --- /dev/null +++ b/apps/server/src/opencode/types.ts @@ -0,0 +1,596 @@ +import type { + ProviderApprovalDecision, + ProviderRuntimeEvent, + ProviderSendTurnInput, + ProviderSession, + ProviderSessionStartInput, +} from "@t3tools/contracts"; +import type { ApprovalRequestId, CanonicalRequestType, ThreadId, TurnId } from "@t3tools/contracts"; + +export const PROVIDER = "opencode" as const; +export const DEFAULT_HOSTNAME = "127.0.0.1"; +export const DEFAULT_PORT = 6733; +export const SERVER_START_TIMEOUT_MS = 5000; +export const SERVER_PROBE_TIMEOUT_MS = 1500; + +// --------------------------------------------------------------------------- +// Provider / Session option types +// --------------------------------------------------------------------------- + +export type OpenCodeProviderOptions = { + readonly serverUrl?: string; + readonly binaryPath?: string; + readonly hostname?: string; + readonly port?: number; + readonly workspace?: string; + readonly username?: string; + readonly password?: string; +}; + +export type OpenCodeSessionStartInput = ProviderSessionStartInput & { + readonly providerOptions?: ProviderSessionStartInput["providerOptions"] & { + readonly opencode?: OpenCodeProviderOptions; + }; +}; + +export type OpenCodeSendTurnInput = ProviderSendTurnInput & { + readonly modelOptions?: ProviderSendTurnInput["modelOptions"] & { + readonly opencode?: { + readonly providerId?: string; + readonly modelId?: string; + readonly variant?: string; + readonly reasoningEffort?: string; + readonly agent?: string; + }; + }; +}; + +// --------------------------------------------------------------------------- +// Runtime event types +// --------------------------------------------------------------------------- + +export type OpenCodeRuntimeRawSource = + | "opencode.server.event" + | "opencode.server.permission" + | "opencode.server.question"; + +export type OpenCodeProviderRuntimeEvent = Omit & { + readonly provider: ProviderRuntimeEvent["provider"] | "opencode"; + readonly raw?: { + readonly source: OpenCodeRuntimeRawSource; + readonly method?: string; + readonly messageType?: string; + readonly payload: unknown; + }; +}; + +export type OpenCodeProviderSession = Omit & { + readonly provider: ProviderSession["provider"] | "opencode"; +}; + +// --------------------------------------------------------------------------- +// Model discovery types +// --------------------------------------------------------------------------- + +export type OpenCodeModel = { + readonly id: string; + readonly name: string; + readonly variants?: Readonly>; +}; + +export type OpenCodeListedProvider = { + readonly id: string; + readonly name?: string; + readonly models: Readonly>; +}; + +export type ProviderListResponse = { + readonly all: ReadonlyArray; + readonly connected: ReadonlyArray; +}; + +export type OpenCodeConfiguredProvider = { + readonly id: string; + readonly name?: string; + readonly models: Readonly>; +}; + +export type ConfigProvidersResponse = { + readonly providers: ReadonlyArray; +}; + +export type OpenCodeDiscoveredModel = { + slug: string; + name: string; + variants?: ReadonlyArray; + connected?: boolean; +}; + +export type OpenCodeModelDiscoveryOptions = OpenCodeProviderOptions & { + directory?: string; +}; + +// --------------------------------------------------------------------------- +// Event payload types +// --------------------------------------------------------------------------- + +export type QuestionInfo = { + readonly header: string; + readonly question: string; + readonly options: ReadonlyArray<{ + readonly label: string; + readonly description: string; + }>; + readonly multiple?: boolean; + readonly custom?: boolean; +}; + +export type OpenCodeTodo = { + readonly content: string; + readonly status: "completed" | "in_progress" | string; + readonly priority?: string; +}; + +export type OpenCodeToolState = + | { + readonly status: "pending"; + } + | { + readonly status: "running"; + readonly title: string; + readonly metadata?: Record; + } + | { + readonly status: "completed"; + readonly title: string; + readonly output?: string; + readonly metadata?: Record; + } + | { + readonly status: "error"; + readonly error: string; + readonly metadata?: Record; + }; + +export type OpenCodeToolPart = { + readonly id: string; + readonly sessionID: string; + readonly messageID?: string; + readonly type: "tool"; + readonly tool?: string; + readonly state: OpenCodeToolState; +}; + +export type OpenCodeMessagePart = + | { + readonly id: string; + readonly sessionID: string; + readonly messageID?: string; + readonly type: "text"; + } + | { + readonly id: string; + readonly sessionID: string; + readonly messageID?: string; + readonly type: "reasoning"; + } + | OpenCodeToolPart; + +// --------------------------------------------------------------------------- +// SSE event types +// --------------------------------------------------------------------------- + +export type EventSessionStatus = { + readonly type: "session.status"; + readonly properties: { + readonly sessionID: string; + readonly status: { + readonly type: "busy" | "retry" | "idle" | string; + }; + }; +}; + +/** + * Matches the SDK's `EventSessionError` type. The `error` union covers all + * known error names from `@opencode-ai/sdk/v2`: + * + * - ProviderAuthError: { providerID, message } + * - UnknownError: { message } + * - MessageAbortedError: { message } + * - StructuredOutputError: { message, retries } + * - ContextOverflowError: { message, responseBody? } + * - APIError: { message, statusCode?, isRetryable, ... } + * - MessageOutputLengthError: { [key: string]: unknown } + */ +export type EventSessionError = { + readonly type: "session.error"; + readonly properties: { + readonly sessionID?: string; + readonly error?: + | { + readonly name: "ProviderAuthError"; + readonly data: { + readonly providerID: string; + readonly message: string; + }; + } + | { + readonly name: "APIError"; + readonly data: { + readonly message: string; + readonly statusCode?: number; + readonly isRetryable: boolean; + readonly responseHeaders?: Record; + readonly responseBody?: string; + readonly metadata?: Record; + }; + } + | { + readonly name: "ContextOverflowError"; + readonly data: { + readonly message: string; + readonly responseBody?: string; + }; + } + | { + readonly name: "StructuredOutputError"; + readonly data: { + readonly message: string; + readonly retries: number; + }; + } + | { + readonly name: "UnknownError" | "MessageAbortedError"; + readonly data: { + readonly message: string; + }; + } + | { + readonly name: "MessageOutputLengthError"; + readonly data?: Record; + } + | { + readonly name: string; + readonly data?: { + readonly message?: string; + }; + }; + }; +}; + +export type EventPermissionAsked = { + readonly type: "permission.asked"; + readonly properties: { + readonly id: string; + readonly sessionID: string; + readonly permission?: string; + readonly title?: string; + readonly pattern?: string; + readonly metadata?: Record; + readonly tool?: string; + }; +}; + +export type EventPermissionReplied = { + readonly type: "permission.replied"; + readonly properties: { + readonly requestID: string; + readonly sessionID: string; + readonly reply: string; + }; +}; + +export type EventQuestionAsked = { + readonly type: "question.asked"; + readonly properties: { + readonly id: string; + readonly sessionID: string; + readonly questions: ReadonlyArray; + }; +}; + +export type EventQuestionReplied = { + readonly type: "question.replied"; + readonly properties: { + readonly requestID: string; + readonly sessionID: string; + readonly answers: ReadonlyArray>; + }; +}; + +export type EventQuestionRejected = { + readonly type: "question.rejected"; + readonly properties: { + readonly requestID: string; + readonly sessionID: string; + }; +}; + +export type EventMessagePartUpdated = { + readonly type: "message.part.updated"; + readonly properties: { + readonly part: OpenCodeMessagePart; + }; +}; + +export type EventMessagePartDelta = { + readonly type: "message.part.delta"; + readonly properties: { + readonly sessionID: string; + readonly partID: string; + readonly delta: string; + }; +}; + +export type EventTodoUpdated = { + readonly type: "todo.updated"; + readonly properties: { + readonly sessionID: string; + readonly todos: ReadonlyArray; + }; +}; + +export type EventSessionIdle = { + readonly type: "session.idle"; + readonly properties: { + readonly sessionID: string; + }; +}; + +export type OpenCodeFileDiff = { + readonly file: string; + readonly before: string; + readonly after: string; + readonly additions: number; + readonly deletions: number; +}; + +export type EventSessionDiff = { + readonly type: "session.diff"; + readonly properties: { + readonly sessionID: string; + readonly diff: ReadonlyArray; + }; +}; + +export type EventSessionCompacted = { + readonly type: "session.compacted"; + readonly properties: { + readonly sessionID: string; + }; +}; + +export type EventSessionUpdated = { + readonly type: "session.updated"; + readonly properties: { + readonly sessionID: string; + readonly info?: { + readonly title?: string; + readonly shareURL?: string; + readonly [key: string]: unknown; + }; + }; +}; + +export type EventVcsBranchUpdated = { + readonly type: "vcs.branch.updated"; + readonly properties: { + readonly sessionID?: string; + readonly branch: string; + }; +}; + +export type EventFileEdited = { + readonly type: "file.edited"; + readonly properties: { + readonly sessionID?: string; + readonly filename: string; + readonly fileId?: string; + }; +}; + +export type EventCommandExecuted = { + readonly type: "command.executed"; + readonly properties: { + readonly sessionID: string; + readonly command: string; + readonly args?: Record; + }; +}; + +export type EventMessagePartRemoved = { + readonly type: "message.part.removed"; + readonly properties: { + readonly sessionID: string; + readonly partID: string; + }; +}; + +export type OpenCodeEvent = + | EventSessionStatus + | EventSessionError + | EventSessionIdle + | EventSessionDiff + | EventSessionCompacted + | EventSessionUpdated + | EventPermissionAsked + | EventPermissionReplied + | EventQuestionAsked + | EventQuestionReplied + | EventQuestionRejected + | EventMessagePartUpdated + | EventMessagePartDelta + | EventMessagePartRemoved + | EventTodoUpdated + | EventVcsBranchUpdated + | EventFileEdited + | EventCommandExecuted; + +// --------------------------------------------------------------------------- +// SDK client types +// --------------------------------------------------------------------------- + +export type OpenCodeDataResponse = + | T + | { + readonly data: T; + readonly error?: undefined; + } + | { + readonly data?: undefined; + readonly error: unknown; + }; + +export type OpencodeClientConfig = { + readonly baseUrl: string; + readonly directory?: string; + readonly responseStyle?: "data" | string; + readonly throwOnError?: boolean; + readonly headers?: Record; +}; + +export type OpencodeClient = { + readonly session: { + readonly get: (input: { + readonly sessionID: string; + readonly workspace?: string; + }) => Promise; + readonly create: (input: { + readonly workspace?: string; + readonly title: string; + }) => Promise; + readonly promptAsync: (input: { + readonly sessionID: string; + readonly workspace?: string; + readonly model?: { + readonly providerID: string; + readonly modelID: string; + }; + readonly agent?: string; + readonly variant?: string; + readonly parts: ReadonlyArray<{ + readonly type: "text"; + readonly text: string; + }>; + }) => Promise; + readonly abort: (input: { + readonly sessionID: string; + readonly workspace?: string; + }) => Promise; + readonly messages: (input: { + readonly sessionID: string; + readonly workspace?: string; + }) => Promise>; + readonly revert: (input: { + readonly sessionID: string; + readonly messageID: string; + readonly workspace?: string; + }) => Promise; + readonly unrevert: (input: { + readonly sessionID: string; + readonly workspace?: string; + }) => Promise; + }; + readonly permission: { + readonly reply: (input: { + readonly requestID: string; + readonly workspace?: string; + readonly reply: "once" | "always" | "reject"; + }) => Promise; + }; + readonly question: { + readonly reply: (input: { + readonly requestID: string; + readonly workspace?: string; + readonly answers: ReadonlyArray>; + }) => Promise; + }; + readonly provider: { + readonly list: (input: { + readonly workspace?: string; + }) => Promise>; + }; + readonly config: { + readonly providers: (input: { + readonly workspace?: string; + }) => Promise>; + }; + readonly event: { + readonly subscribe: ( + input: { + readonly workspace?: string; + }, + options: { + readonly signal?: AbortSignal; + }, + ) => Promise<{ + readonly stream: AsyncIterable; + }>; + }; +}; + +export type OpenCodeSdkModule = { + createOpencodeClient(options: OpencodeClientOptions): OpencodeClient; +}; + +export type OpencodeClientOptions = OpencodeClientConfig & { + directory?: string; +}; + +// --------------------------------------------------------------------------- +// Session context types +// --------------------------------------------------------------------------- + +export interface PendingPermissionRequest { + readonly requestId: ApprovalRequestId; + readonly requestType: CanonicalRequestType; +} + +export interface PendingQuestionRequest { + readonly requestId: ApprovalRequestId; + readonly questionIds: ReadonlyArray; + readonly questions: ReadonlyArray<{ + readonly answerIndex: number; + readonly id: string; + readonly header: string; + readonly question: string; + readonly options: ReadonlyArray<{ + readonly label: string; + readonly description: string; + }>; + }>; +} + +export interface PartStreamState { + readonly kind: "text" | "reasoning" | "tool"; + readonly streamKind?: "assistant_text" | "reasoning_text"; +} + +export interface OpenCodeSessionContext { + readonly threadId: ThreadId; + readonly directory: string; + readonly workspace?: string; + readonly client: OpencodeClient; + readonly providerSessionId: string; + readonly pendingPermissions: Map; + readonly pendingQuestions: Map; + readonly partStreamById: Map; + readonly messageIds: string[]; + readonly streamAbortController: AbortController; + streamTask: Promise; + session: OpenCodeProviderSession; + activeTurnId: TurnId | undefined; + lastError: string | undefined; +} + +export interface SharedServerState { + readonly baseUrl: string; + readonly authHeader?: string; + readonly child?: { + kill: () => boolean; + }; +} + +export interface OpenCodeManagerEvents { + event: [ProviderRuntimeEvent]; +} diff --git a/apps/server/src/opencode/utils.ts b/apps/server/src/opencode/utils.ts new file mode 100644 index 0000000000..5d6fd3a6f9 --- /dev/null +++ b/apps/server/src/opencode/utils.ts @@ -0,0 +1,381 @@ +import { randomUUID } from "node:crypto"; + +import { EventId, TurnId, type CanonicalRequestType, type ProviderApprovalDecision } from "@t3tools/contracts"; + +import type { + ConfigProvidersResponse, + OpenCodeConfiguredProvider, + OpenCodeDiscoveredModel, + OpenCodeFileDiff, + OpenCodeListedProvider, + OpenCodeModel, + OpenCodeProviderSession, + OpenCodeTodo, + OpenCodeToolState, + ProviderListResponse, +} from "./types.ts"; + +// --------------------------------------------------------------------------- +// Generic helpers +// --------------------------------------------------------------------------- + +export function asRecord(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + return value as Record; +} + +export function asString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +export function eventId(prefix: string): EventId { + return EventId.makeUnsafe(`${prefix}:${randomUUID()}`); +} + +export function nowIso(): string { + return new Date().toISOString(); +} + +export function createTurnId(): TurnId { + return TurnId.makeUnsafe(`turn:${randomUUID()}`); +} + +export function textPart(text: string) { + return { + type: "text" as const, + text, + }; +} + +// --------------------------------------------------------------------------- +// Auth +// --------------------------------------------------------------------------- + +export function buildAuthHeader(username?: string, password?: string): string | undefined { + if (!password) { + return undefined; + } + const resolvedUsername = username && username.length > 0 ? username : "opencode"; + return `Basic ${Buffer.from(`${resolvedUsername}:${password}`).toString("base64")}`; +} + +// --------------------------------------------------------------------------- +// Session / resume helpers +// --------------------------------------------------------------------------- + +export function readResumeSessionId(resumeCursor: unknown): string | undefined { + const record = asRecord(resumeCursor); + return asString(record?.sessionId); +} + +export function stripTransientSessionFields(session: OpenCodeProviderSession) { + const { activeTurnId: _activeTurnId, lastError: _lastError, ...rest } = session; + return rest; +} + +// --------------------------------------------------------------------------- +// Model parsing +// --------------------------------------------------------------------------- + +export function parseOpencodeModel(model: string | undefined): + | { + providerId: string; + modelId: string; + variant?: string; + } + | undefined { + const value = asString(model); + if (!value) { + return undefined; + } + const index = value.indexOf("/"); + if (index < 1 || index >= value.length - 1) { + return undefined; + } + const providerId = value.slice(0, index); + const modelAndVariant = value.slice(index + 1); + const variantIndex = modelAndVariant.lastIndexOf("#"); + const modelId = variantIndex >= 1 ? modelAndVariant.slice(0, variantIndex) : modelAndVariant; + const variant = + variantIndex >= 1 && variantIndex < modelAndVariant.length - 1 + ? modelAndVariant.slice(variantIndex + 1) + : undefined; + return { + providerId, + modelId, + ...(variant ? { variant } : {}), + }; +} + +const PREFERRED_VARIANT_ORDER = [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +] as const; + +function compareOpenCodeVariantNames(left: string, right: string): number { + const leftIndex = PREFERRED_VARIANT_ORDER.indexOf( + left as (typeof PREFERRED_VARIANT_ORDER)[number], + ); + const rightIndex = PREFERRED_VARIANT_ORDER.indexOf( + right as (typeof PREFERRED_VARIANT_ORDER)[number], + ); + if (leftIndex >= 0 || rightIndex >= 0) { + if (leftIndex < 0) return 1; + if (rightIndex < 0) return -1; + if (leftIndex !== rightIndex) return leftIndex - rightIndex; + } + return left.localeCompare(right); +} + +function modelOptionsFromProvider( + providerId: string, + providerName: string, + model: OpenCodeModel, + connected?: boolean, +): ReadonlyArray { + const variantNames = Object.keys(model.variants ?? {}) + .filter((variant) => variant.length > 0) + .toSorted(compareOpenCodeVariantNames); + return [ + { + slug: `${providerId}/${model.id}`, + name: `${providerName} / ${model.name}`, + ...(variantNames.length > 0 ? { variants: variantNames } : {}), + ...(connected != null ? { connected } : {}), + }, + ]; +} + +export function parseProviderModels( + providers: ReadonlyArray< + Pick | OpenCodeConfiguredProvider + >, + connectedIds?: ReadonlySet, +): ReadonlyArray { + const sorted = [...providers].sort((a, b) => { + const nameA = a.name || a.id; + const nameB = b.name || b.id; + return nameA.localeCompare(nameB); + }); + return sorted.flatMap((provider) => { + const providerName = provider.name || provider.id; + const isConnected = connectedIds ? connectedIds.has(provider.id) : undefined; + return Object.values(provider.models).flatMap((model) => + modelOptionsFromProvider(provider.id, providerName, model, isConnected), + ); + }); +} + +// --------------------------------------------------------------------------- +// Permission / request type mapping +// --------------------------------------------------------------------------- + +export function toOpencodeRequestType(permission: string | undefined): CanonicalRequestType { + switch (permission) { + case "bash": + return "exec_command_approval"; + case "edit": + case "write": + return "file_change_approval"; + case "read": + case "glob": + case "grep": + case "list": + case "codesearch": + case "lsp": + case "external_directory": + return "file_read_approval"; + default: + return "unknown"; + } +} + +export function toPermissionReply(decision: ProviderApprovalDecision): "once" | "always" | "reject" { + switch (decision) { + case "acceptForSession": + return "always"; + case "accept": + return "once"; + case "decline": + case "cancel": + return "reject"; + } +} + +// --------------------------------------------------------------------------- +// Tool state helpers +// --------------------------------------------------------------------------- + +function readMetadataString( + metadata: Record | undefined, + key: string, +): string | undefined { + const value = metadata?.[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +export function toolStateTitle(state: OpenCodeToolState): string | undefined { + switch (state.status) { + case "pending": + return undefined; + case "running": + case "completed": + return state.title; + case "error": + return readMetadataString(state.metadata, "title"); + } +} + +export function toolStateDetail(state: OpenCodeToolState): string | undefined { + switch (state.status) { + case "pending": + return undefined; + case "running": + return readMetadataString(state.metadata, "summary") ?? state.title; + case "completed": + return readMetadataString(state.metadata, "summary") ?? state.output; + case "error": + return state.error; + } +} + +export function toPlanStepStatus(status: string): "pending" | "inProgress" | "completed" { + switch (status) { + case "completed": + return "completed"; + case "in_progress": + return "inProgress"; + default: + return "pending"; + } +} + +export function toToolItemType( + toolName: string | undefined, +): + | "command_execution" + | "file_change" + | "web_search" + | "collab_agent_tool_call" + | "dynamic_tool_call" { + switch (toolName) { + case "bash": + return "command_execution"; + case "write": + case "edit": + case "apply_patch": + return "file_change"; + case "webfetch": + return "web_search"; + case "task": + return "collab_agent_tool_call"; + default: + return "dynamic_tool_call"; + } +} + +export function toToolTitle(toolName: string | undefined): string { + const value = asString(toolName) ?? "tool"; + return value.slice(0, 1).toUpperCase() + value.slice(1); +} + +export function toToolLifecycleEventType( + previous: { kind: string } | undefined, + status: OpenCodeToolState["status"], +): "item.started" | "item.updated" | "item.completed" { + if (status === "completed" || status === "error") { + return "item.completed"; + } + return previous?.kind === "tool" ? "item.updated" : "item.started"; +} + +// --------------------------------------------------------------------------- +// Server URL parsing +// --------------------------------------------------------------------------- + +export function parseServerUrl(output: string): string | undefined { + const match = output.match(/opencode server listening on\s+(https?:\/\/[^\s]+)(?=\r?\n)/); + return match?.[1]; +} + +// --------------------------------------------------------------------------- +// SDK response helpers +// --------------------------------------------------------------------------- + +export async function readJsonData(promise: Promise): Promise { + return promise; +} + +export function readProviderListResponse( + value: + | ProviderListResponse + | { data: ProviderListResponse; error?: undefined } + | { data?: undefined; error: unknown }, +): ProviderListResponse { + if ("all" in value && "connected" in value) { + return value; + } + if (value.data !== undefined) { + return value.data; + } + throw new Error("OpenCode SDK returned an empty provider list response"); +} + +export function readConfigProvidersResponse( + value: + | ConfigProvidersResponse + | { data: ConfigProvidersResponse; error?: undefined } + | { data?: undefined; error: unknown }, +): ConfigProvidersResponse { + if ("providers" in value) { + return value; + } + if (value.data !== undefined) { + return value.data; + } + throw new Error("OpenCode SDK returned an empty config providers response"); +} + +// --------------------------------------------------------------------------- +// Diff helpers +// --------------------------------------------------------------------------- + +/** + * Converts an array of OpenCode file diffs into a single unified diff string. + * The format approximates standard unified diff output (--- a/file, +++ b/file, + * with addition/deletion counts) without full line-level hunks since OpenCode + * only provides before/after snapshots and summary counts. + */ +export function fileDiffsToUnifiedDiff(diffs: ReadonlyArray): string { + if (diffs.length === 0) { + return ""; + } + return diffs + .map((d) => { + const header = `--- a/${d.file}\n+++ b/${d.file}`; + const stats = `@@ +${d.additions},-${d.deletions} @@`; + return `${header}\n${stats}`; + }) + .join("\n"); +} + +// --------------------------------------------------------------------------- +// Todo / plan helpers +// --------------------------------------------------------------------------- + +/** + * Prefixes a todo's content with its priority when available, e.g. `"[HIGH] task"`. + */ +export function todoPriorityPrefix(todo: OpenCodeTodo): string { + if (todo.priority && todo.priority.length > 0) { + return `[${todo.priority.toUpperCase()}] ${todo.content}`; + } + return todo.content; +} diff --git a/apps/server/src/opencodeServerManager.ts b/apps/server/src/opencodeServerManager.ts index 0d4526f05b..928693675a 100644 --- a/apps/server/src/opencodeServerManager.ts +++ b/apps/server/src/opencodeServerManager.ts @@ -1,15 +1,10 @@ import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; -import { spawn } from "node:child_process"; import { ApprovalRequestId, - EventId, - RuntimeItemId, - RuntimeRequestId, ThreadId, TurnId, - type CanonicalRequestType, type ProviderApprovalDecision, type ProviderRuntimeEvent, type ProviderSendTurnInput, @@ -20,773 +15,39 @@ import { } from "@t3tools/contracts"; import type { ProviderThreadSnapshot } from "./provider/Services/ProviderAdapter.ts"; -const PROVIDER = "opencode" as const; -const DEFAULT_HOSTNAME = "127.0.0.1"; -const DEFAULT_PORT = 6733; -const SERVER_START_TIMEOUT_MS = 5000; -const SERVER_PROBE_TIMEOUT_MS = 1500; - -type OpenCodeProviderOptions = { - readonly serverUrl?: string; - readonly binaryPath?: string; - readonly hostname?: string; - readonly port?: number; - readonly workspace?: string; - readonly username?: string; - readonly password?: string; -}; - -type OpenCodeSessionStartInput = ProviderSessionStartInput & { - readonly providerOptions?: ProviderSessionStartInput["providerOptions"] & { - readonly opencode?: OpenCodeProviderOptions; - }; -}; - -type OpenCodeSendTurnInput = ProviderSendTurnInput & { - readonly modelOptions?: ProviderSendTurnInput["modelOptions"] & { - readonly opencode?: { - readonly providerId?: string; - readonly modelId?: string; - readonly variant?: string; - readonly reasoningEffort?: string; - readonly agent?: string; - }; - }; -}; - -type OpenCodeRuntimeRawSource = - | "opencode.server.event" - | "opencode.server.permission" - | "opencode.server.question"; - -type OpenCodeProviderRuntimeEvent = Omit & { - readonly provider: ProviderRuntimeEvent["provider"] | "opencode"; - readonly raw?: { - readonly source: OpenCodeRuntimeRawSource; - readonly method?: string; - readonly messageType?: string; - readonly payload: unknown; - }; -}; - -type OpenCodeProviderSession = Omit & { - readonly provider: ProviderSession["provider"] | "opencode"; -}; - -type OpenCodeModel = { - readonly id: string; - readonly name: string; - readonly variants?: Readonly>; -}; - -type OpenCodeListedProvider = { - readonly id: string; - readonly name?: string; - readonly models: Readonly>; -}; - -type ProviderListResponse = { - readonly all: ReadonlyArray; - readonly connected: ReadonlyArray; -}; - -type OpenCodeConfiguredProvider = { - readonly id: string; - readonly name?: string; - readonly models: Readonly>; -}; - -type ConfigProvidersResponse = { - readonly providers: ReadonlyArray; -}; - -type QuestionInfo = { - readonly header: string; - readonly question: string; - readonly options: ReadonlyArray<{ - readonly label: string; - readonly description: string; - }>; -}; - -type OpenCodeTodo = { - readonly content: string; - readonly status: "completed" | "in_progress" | string; -}; - -type OpenCodeToolState = - | { - readonly status: "pending"; - } - | { - readonly status: "running"; - readonly title: string; - readonly metadata?: Record; - } - | { - readonly status: "completed"; - readonly title: string; - readonly output?: string; - readonly metadata?: Record; - } - | { - readonly status: "error"; - readonly error: string; - readonly metadata?: Record; - }; - -type OpenCodeToolPart = { - readonly id: string; - readonly sessionID: string; - readonly type: "tool"; - readonly tool?: string; - readonly state: OpenCodeToolState; -}; - -type OpenCodeMessagePart = - | { - readonly id: string; - readonly sessionID: string; - readonly type: "text"; - } - | { - readonly id: string; - readonly sessionID: string; - readonly type: "reasoning"; - } - | OpenCodeToolPart; - -type EventSessionStatus = { - readonly type: "session.status"; - readonly properties: { - readonly sessionID: string; - readonly status: { - readonly type: "busy" | "retry" | "idle" | string; - }; - }; -}; - -type EventSessionError = { - readonly type: "session.error"; - readonly properties: { - readonly sessionID?: string; - readonly error?: - | { - readonly name: - | "ProviderAuthError" - | "UnknownError" - | "MessageAbortedError" - | "StructuredOutputError" - | "ContextOverflowError" - | "APIError"; - readonly data: { - readonly message: string; - }; - } - | { - readonly name: "MessageOutputLengthError"; - readonly data?: Record; - } - | { - readonly name: string; - readonly data?: { - readonly message?: string; - }; - }; - }; -}; - -type EventPermissionAsked = { - readonly type: "permission.asked"; - readonly properties: { - readonly id: string; - readonly sessionID: string; - readonly permission?: string; - }; -}; - -type EventPermissionReplied = { - readonly type: "permission.replied"; - readonly properties: { - readonly requestID: string; - readonly sessionID: string; - readonly reply: string; - }; -}; - -type EventQuestionAsked = { - readonly type: "question.asked"; - readonly properties: { - readonly id: string; - readonly sessionID: string; - readonly questions: ReadonlyArray; - }; -}; - -type EventQuestionReplied = { - readonly type: "question.replied"; - readonly properties: { - readonly requestID: string; - readonly sessionID: string; - readonly answers: ReadonlyArray>; - }; -}; - -type EventQuestionRejected = { - readonly type: "question.rejected"; - readonly properties: { - readonly requestID: string; - readonly sessionID: string; - }; -}; - -type EventMessagePartUpdated = { - readonly type: "message.part.updated"; - readonly properties: { - readonly part: OpenCodeMessagePart; - }; -}; - -type EventMessagePartDelta = { - readonly type: "message.part.delta"; - readonly properties: { - readonly sessionID: string; - readonly partID: string; - readonly delta: string; - }; -}; - -type EventTodoUpdated = { - readonly type: "todo.updated"; - readonly properties: { - readonly sessionID: string; - readonly todos: ReadonlyArray; - }; -}; - -type EventSessionIdle = { - readonly type: "session.idle"; - readonly properties: { - readonly sessionID: string; - }; -}; - -type OpenCodeFileDiff = { - readonly file: string; - readonly before: string; - readonly after: string; - readonly additions: number; - readonly deletions: number; -}; - -type EventSessionDiff = { - readonly type: "session.diff"; - readonly properties: { - readonly sessionID: string; - readonly diff: ReadonlyArray; - }; -}; - -type OpenCodeEvent = - | EventSessionStatus - | EventSessionError - | EventSessionIdle - | EventSessionDiff - | EventPermissionAsked - | EventPermissionReplied - | EventQuestionAsked - | EventQuestionReplied - | EventQuestionRejected - | EventMessagePartUpdated - | EventMessagePartDelta - | EventTodoUpdated; - -type OpenCodeDataResponse = - | T - | { - readonly data: T; - readonly error?: undefined; - } - | { - readonly data?: undefined; - readonly error: unknown; - }; - -type OpencodeClientConfig = { - readonly baseUrl: string; - readonly directory?: string; - readonly responseStyle?: "data" | string; - readonly throwOnError?: boolean; - readonly headers?: Record; -}; - -type OpencodeClient = { - readonly session: { - readonly get: (input: { - readonly sessionID: string; - readonly workspace?: string; - }) => Promise; - readonly create: (input: { - readonly workspace?: string; - readonly title: string; - }) => Promise; - readonly promptAsync: (input: { - readonly sessionID: string; - readonly workspace?: string; - readonly model?: { - readonly providerID: string; - readonly modelID: string; - }; - readonly agent?: string; - readonly variant?: string; - readonly parts: ReadonlyArray<{ - readonly type: "text"; - readonly text: string; - }>; - }) => Promise; - readonly abort: (input: { - readonly sessionID: string; - readonly workspace?: string; - }) => Promise; - readonly messages: (input: { - readonly sessionID: string; - readonly workspace?: string; - }) => Promise>; - }; - readonly permission: { - readonly reply: (input: { - readonly requestID: string; - readonly workspace?: string; - readonly reply: "once" | "always" | "reject"; - }) => Promise; - }; - readonly question: { - readonly reply: (input: { - readonly requestID: string; - readonly workspace?: string; - readonly answers: ReadonlyArray>; - }) => Promise; - }; - readonly provider: { - readonly list: (input: { - readonly workspace?: string; - }) => Promise>; - }; - readonly config: { - readonly providers: (input: { - readonly workspace?: string; - }) => Promise>; - }; - readonly event: { - readonly subscribe: ( - input: { - readonly workspace?: string; - }, - options: { - readonly signal?: AbortSignal; - }, - ) => Promise<{ - readonly stream: AsyncIterable; - }>; - }; -}; - -type OpenCodeSdkModule = { - createOpencodeClient(options: OpencodeClientOptions): OpencodeClient; -}; - -type OpencodeClientOptions = OpencodeClientConfig & { - directory?: string; -}; -type OpenCodeModelDiscoveryOptions = OpenCodeProviderOptions & { - directory?: string; -}; -type OpenCodeDiscoveredModel = { - slug: string; - name: string; - variants?: ReadonlyArray; - connected?: boolean; -}; - -interface OpenCodeManagerEvents { - event: [ProviderRuntimeEvent]; -} - -interface PendingPermissionRequest { - readonly requestId: ApprovalRequestId; - readonly requestType: CanonicalRequestType; -} - -interface PendingQuestionRequest { - readonly requestId: ApprovalRequestId; - readonly questionIds: ReadonlyArray; - readonly questions: ReadonlyArray<{ - readonly answerIndex: number; - readonly id: string; - readonly header: string; - readonly question: string; - readonly options: ReadonlyArray<{ - readonly label: string; - readonly description: string; - }>; - }>; -} - -interface PartStreamState { - readonly kind: "text" | "reasoning" | "tool"; - readonly streamKind?: "assistant_text" | "reasoning_text"; -} - -interface OpenCodeSessionContext { - readonly threadId: ThreadId; - readonly directory: string; - readonly workspace?: string; - readonly client: OpencodeClient; - readonly providerSessionId: string; - readonly pendingPermissions: Map; - readonly pendingQuestions: Map; - readonly partStreamById: Map; - readonly streamAbortController: AbortController; - streamTask: Promise; - session: OpenCodeProviderSession; - activeTurnId: TurnId | undefined; - lastError: string | undefined; -} - -interface SharedServerState { - readonly baseUrl: string; - readonly authHeader?: string; - readonly child?: { - kill: () => boolean; - }; -} - -function asRecord(value: unknown): Record | undefined { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return undefined; - } - return value as Record; -} - -function asString(value: unknown): string | undefined { - return typeof value === "string" && value.length > 0 ? value : undefined; -} - -function eventId(prefix: string): EventId { - return EventId.makeUnsafe(`${prefix}:${randomUUID()}`); -} - -function nowIso(): string { - return new Date().toISOString(); -} - -function buildAuthHeader(username?: string, password?: string): string | undefined { - if (!password) { - return undefined; - } - const resolvedUsername = username && username.length > 0 ? username : "opencode"; - return `Basic ${Buffer.from(`${resolvedUsername}:${password}`).toString("base64")}`; -} - -function parseServerUrl(output: string): string | undefined { - const match = output.match(/opencode server listening on\s+(https?:\/\/[^\s]+)(?=\r?\n)/); - return match?.[1]; -} - -async function probeServer(baseUrl: string, authHeader?: string): Promise { - const response = await fetch(`${baseUrl}/global/health`, { - method: "GET", - ...(authHeader ? { headers: { Authorization: authHeader } } : {}), - signal: AbortSignal.timeout(SERVER_PROBE_TIMEOUT_MS), - }).catch(() => undefined); - return response?.ok === true; -} - -function readResumeSessionId(resumeCursor: unknown): string | undefined { - const record = asRecord(resumeCursor); - return asString(record?.sessionId); -} - -function parseOpencodeModel(model: string | undefined): - | { - providerId: string; - modelId: string; - variant?: string; - } - | undefined { - const value = asString(model); - if (!value) { - return undefined; - } - const index = value.indexOf("/"); - if (index < 1 || index >= value.length - 1) { - return undefined; - } - const providerId = value.slice(0, index); - const modelAndVariant = value.slice(index + 1); - const variantIndex = modelAndVariant.lastIndexOf("#"); - const modelId = variantIndex >= 1 ? modelAndVariant.slice(0, variantIndex) : modelAndVariant; - const variant = - variantIndex >= 1 && variantIndex < modelAndVariant.length - 1 - ? modelAndVariant.slice(variantIndex + 1) - : undefined; - return { - providerId, - modelId, - ...(variant ? { variant } : {}), - }; -} - -const PREFERRED_VARIANT_ORDER = [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh", - "max", -] as const; - -function compareOpenCodeVariantNames(left: string, right: string): number { - const leftIndex = PREFERRED_VARIANT_ORDER.indexOf( - left as (typeof PREFERRED_VARIANT_ORDER)[number], - ); - const rightIndex = PREFERRED_VARIANT_ORDER.indexOf( - right as (typeof PREFERRED_VARIANT_ORDER)[number], - ); - if (leftIndex >= 0 || rightIndex >= 0) { - if (leftIndex < 0) return 1; - if (rightIndex < 0) return -1; - if (leftIndex !== rightIndex) return leftIndex - rightIndex; - } - return left.localeCompare(right); -} - -function modelOptionsFromProvider( - providerId: string, - providerName: string, - model: OpenCodeModel, - connected?: boolean, -): ReadonlyArray { - const variantNames = Object.keys(model.variants ?? {}) - .filter((variant) => variant.length > 0) - .toSorted(compareOpenCodeVariantNames); - return [ - { - slug: `${providerId}/${model.id}`, - name: `${providerName} / ${model.name}`, - ...(variantNames.length > 0 ? { variants: variantNames } : {}), - ...(connected != null ? { connected } : {}), - }, - ]; -} - -function parseProviderModels( - providers: ReadonlyArray< - Pick | OpenCodeConfiguredProvider - >, - connectedIds?: ReadonlySet, -): ReadonlyArray { - const sorted = [...providers].sort((a, b) => { - const nameA = a.name || a.id; - const nameB = b.name || b.id; - return nameA.localeCompare(nameB); - }); - return sorted.flatMap((provider) => { - const providerName = provider.name || provider.id; - const isConnected = connectedIds ? connectedIds.has(provider.id) : undefined; - return Object.values(provider.models).flatMap((model) => - modelOptionsFromProvider(provider.id, providerName, model, isConnected), - ); - }); -} - -function toOpencodeRequestType(permission: string | undefined): CanonicalRequestType { - switch (permission) { - case "bash": - return "exec_command_approval"; - case "edit": - case "write": - return "file_change_approval"; - case "read": - case "glob": - case "grep": - case "list": - case "codesearch": - case "lsp": - case "external_directory": - return "file_read_approval"; - default: - return "unknown"; - } -} - -function toPermissionReply(decision: ProviderApprovalDecision): "once" | "always" | "reject" { - switch (decision) { - case "acceptForSession": - return "always"; - case "accept": - return "once"; - case "decline": - case "cancel": - return "reject"; - } -} - -function createTurnId(): TurnId { - return TurnId.makeUnsafe(`turn:${randomUUID()}`); -} - -function textPart(text: string) { - return { - type: "text" as const, - text, - }; -} - -function readMetadataString( - metadata: Record | undefined, - key: string, -): string | undefined { - const value = metadata?.[key]; - return typeof value === "string" && value.length > 0 ? value : undefined; -} - -function sessionErrorMessage(error: EventSessionError["properties"]["error"]): string | undefined { - if (!error) { - return undefined; - } - - switch (error.name) { - case "ProviderAuthError": - case "UnknownError": - case "MessageAbortedError": - case "StructuredOutputError": - case "ContextOverflowError": - case "APIError": - return error.data?.message; - case "MessageOutputLengthError": - return "OpenCode response exceeded output length"; - default: { - const msg = (error.data as { message?: string } | undefined)?.message; - return msg ?? `OpenCode error: ${error.name}`; - } - } -} - -function toolStateTitle(state: OpenCodeToolState): string | undefined { - switch (state.status) { - case "pending": - return undefined; - case "running": - case "completed": - return state.title; - case "error": - return readMetadataString(state.metadata, "title"); - } -} - -function toolStateDetail(state: OpenCodeToolState): string | undefined { - switch (state.status) { - case "pending": - return undefined; - case "running": - return readMetadataString(state.metadata, "summary") ?? state.title; - case "completed": - return readMetadataString(state.metadata, "summary") ?? state.output; - case "error": - return state.error; - } -} - -function toPlanStepStatus(status: OpenCodeTodo["status"]): "pending" | "inProgress" | "completed" { - switch (status) { - case "completed": - return "completed"; - case "in_progress": - return "inProgress"; - default: - return "pending"; - } -} - -function toToolItemType( - toolName: string | undefined, -): - | "command_execution" - | "file_change" - | "web_search" - | "collab_agent_tool_call" - | "dynamic_tool_call" { - switch (toolName) { - case "bash": - return "command_execution"; - case "write": - case "edit": - case "apply_patch": - return "file_change"; - case "webfetch": - return "web_search"; - case "task": - return "collab_agent_tool_call"; - default: - return "dynamic_tool_call"; - } -} - -function toToolTitle(toolName: string | undefined): string { - const value = asString(toolName) ?? "tool"; - return value.slice(0, 1).toUpperCase() + value.slice(1); -} - -function toToolLifecycleEventType( - previous: PartStreamState | undefined, - status: OpenCodeToolState["status"], -): "item.started" | "item.updated" | "item.completed" { - if (status === "completed" || status === "error") { - return "item.completed"; - } - return previous?.kind === "tool" ? "item.updated" : "item.started"; -} - -async function readJsonData(promise: Promise): Promise { - return promise; -} - -function readProviderListResponse( - value: - | ProviderListResponse - | { data: ProviderListResponse; error?: undefined } - | { data?: undefined; error: unknown }, -): ProviderListResponse { - if ("all" in value && "connected" in value) { - return value; - } - if (value.data !== undefined) { - return value.data; - } - throw new Error("OpenCode SDK returned an empty provider list response"); -} - -function readConfigProvidersResponse( - value: - | ConfigProvidersResponse - | { data: ConfigProvidersResponse; error?: undefined } - | { data?: undefined; error: unknown }, -): ConfigProvidersResponse { - if ("providers" in value) { - return value; - } - if (value.data !== undefined) { - return value.data; - } - throw new Error("OpenCode SDK returned an empty config providers response"); -} - -function stripTransientSessionFields(session: OpenCodeProviderSession) { - const { activeTurnId: _activeTurnId, lastError: _lastError, ...rest } = session; - return rest; -} +import { + PROVIDER, + type OpenCodeManagerEvents, + type OpenCodeModelDiscoveryOptions, + type OpenCodeProviderOptions, + type OpenCodeProviderRuntimeEvent, + type OpenCodeProviderSession, + type OpenCodeSessionContext, + type OpenCodeSessionStartInput, + type OpenCodeSendTurnInput, + type OpenCodeDiscoveredModel, + type SharedServerState, +} from "./opencode/types.ts"; +import { + asRecord, + asString, + createTurnId, + eventId, + nowIso, + parseOpencodeModel, + parseProviderModels, + readConfigProvidersResponse, + readJsonData, + readProviderListResponse, + readResumeSessionId, + stripTransientSessionFields, + textPart, + toPermissionReply, +} from "./opencode/utils.ts"; +import { handleEvent } from "./opencode/eventHandlers.ts"; +import { createClient, ensureServer } from "./opencode/serverLifecycle.ts"; + +export { type OpenCodeDiscoveredModel, type OpenCodeModelDiscoveryOptions } from "./opencode/types.ts"; export class OpenCodeServerManager extends EventEmitter { private readonly sessions = new Map(); @@ -812,7 +73,7 @@ export class OpenCodeServerManager extends EventEmitter { const options = openCodeInput.providerOptions?.opencode; const workspace = options?.workspace; const sharedServer = await this.ensureServer(options); - const client = await this.createClient({ + const client = await createClient({ baseUrl: sharedServer.baseUrl, directory, responseStyle: "data", @@ -876,6 +137,7 @@ export class OpenCodeServerManager extends EventEmitter { pendingPermissions: new Map(), pendingQuestions: new Map(), partStreamById: new Map(), + messageIds: [], streamAbortController, streamTask: Promise.resolve(), session: initialSession, @@ -922,6 +184,23 @@ export class OpenCodeServerManager extends EventEmitter { }, }); + this.emitRuntimeEvent({ + type: "session.configured", + eventId: eventId("opencode-session-configured"), + provider: PROVIDER, + threadId: openCodeInput.threadId, + createdAt, + payload: { + config: { + provider: PROVIDER, + sessionId: providerSessionId, + ...(openCodeInput.model ? { model: openCodeInput.model } : {}), + directory, + ...(workspace ? { workspace } : {}), + }, + }, + }); + return initialSession as ProviderSession; } @@ -1171,15 +450,33 @@ export class OpenCodeServerManager extends EventEmitter { }; } - async rollbackThread(threadId: ThreadId): Promise { - throw new Error(`OpenCode rollback is not implemented for thread '${threadId}'`); + async rollbackThread(threadId: ThreadId, numTurns = 1): Promise { + const context = this.requireSession(threadId); + const ids = context.messageIds; + if (ids.length === 0) { + throw new Error(`No tracked messages for OpenCode thread '${threadId}' — cannot rollback`); + } + // Target the message just before the last `numTurns` messages. + // Each message ID in the tracked list corresponds to one assistant turn. + const targetIndex = Math.max(0, ids.length - numTurns - 1); + const targetMessageId = ids[targetIndex]!; + await readJsonData( + context.client.session.revert({ + sessionID: context.providerSessionId, + messageID: targetMessageId, + ...(context.workspace ? { workspace: context.workspace } : {}), + }), + ); + // Trim tracked IDs to match the reverted state + context.messageIds.length = targetIndex + 1; + return this.readThread(threadId); } async listModels( options?: OpenCodeModelDiscoveryOptions, ): Promise> { const shared = await this.ensureServer(options); - const client = await this.createClient({ + const client = await createClient({ baseUrl: shared.baseUrl, ...(options?.directory ? { directory: options.directory } : {}), responseStyle: "data", @@ -1218,6 +515,18 @@ export class OpenCodeServerManager extends EventEmitter { if (!context) { return; } + this.emitRuntimeEvent({ + type: "session.exited", + eventId: eventId("opencode-session-exited"), + provider: PROVIDER, + threadId, + createdAt: nowIso(), + payload: { + reason: "Session stopped", + exitKind: "graceful", + recoverable: true, + }, + }); context.streamAbortController.abort(); context.session = { ...stripTransientSessionFields(context.session), @@ -1253,110 +562,12 @@ export class OpenCodeServerManager extends EventEmitter { } this.serverPromise = (async () => { - const authHeader = buildAuthHeader(options?.username, options?.password); - if (options?.serverUrl) { - const shared = { - baseUrl: options.serverUrl, - ...(authHeader ? { authHeader } : {}), - } satisfies SharedServerState; - this.server = shared; - return shared; - } - - const hostname = options?.hostname ?? DEFAULT_HOSTNAME; - const port = Math.trunc(options?.port ?? DEFAULT_PORT); - const baseUrl = `http://${hostname}:${port}`; - const healthy = await probeServer(baseUrl, authHeader); - if (healthy) { - const shared = { - baseUrl, - ...(authHeader ? { authHeader } : {}), - } satisfies SharedServerState; - this.server = shared; - return shared; - } - - const binaryPath = options?.binaryPath ?? "opencode"; - const child = spawn(binaryPath, ["serve", `--hostname=${hostname}`, `--port=${port}`], { - env: { - ...process.env, - ...(options?.username ? { OPENCODE_SERVER_USERNAME: options.username } : {}), - ...(options?.password ? { OPENCODE_SERVER_PASSWORD: options.password } : {}), - }, - stdio: ["ignore", "pipe", "pipe"], + const result = await ensureServer(options, { + server: this.server, + serverPromise: this.serverPromise, }); - - const startedBaseUrl = await new Promise((resolve, reject) => { - let output = ""; - - const onChunk = (chunk: Buffer) => { - output += chunk.toString(); - const url = parseServerUrl(output); - if (!url) { - return; - } - cleanup(); - resolve(url); - }; - - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - - const onExit = (code: number | null) => { - cleanup(); - void probeServer(baseUrl, authHeader).then((reuse) => { - if (reuse) { - resolve(baseUrl); - return; - } - const detail = output.trim().replaceAll(/\s+/g, " ").slice(0, 400); - reject( - new Error( - `OpenCode server exited before startup completed (code ${code})${ - detail.length > 0 ? `: ${detail}` : "" - }`, - ), - ); - }); - }; - - const cleanup = () => { - clearTimeout(timeout); - child.stdout.off("data", onChunk); - child.stderr.off("data", onChunk); - child.off("error", onError); - child.off("exit", onExit); - }; - - const timeout = setTimeout(() => { - cleanup(); - try { - child.kill(); - } catch { - // Process may already be dead. - } - reject( - new Error( - `Timed out waiting for OpenCode server to start after ${SERVER_START_TIMEOUT_MS}ms`, - ), - ); - }, SERVER_START_TIMEOUT_MS); - - child.stdout.on("data", onChunk); - child.stderr.on("data", onChunk); - child.once("error", onError); - child.once("exit", onExit); - }); - - const shared = { - baseUrl: startedBaseUrl, - child, - ...(authHeader ? { authHeader } : {}), - } satisfies SharedServerState; - this.server = shared; - return shared; + this.server = result.state; + return result.state; })(); try { @@ -1368,12 +579,6 @@ export class OpenCodeServerManager extends EventEmitter { } } - private async createClient(options: OpencodeClientOptions): Promise { - const sdkModuleId = "@opencode-ai/sdk/v2/client"; - const sdk = (await import(sdkModuleId)) as OpenCodeSdkModule; - return sdk.createOpencodeClient(options); - } - private async startStream(context: OpenCodeSessionContext): Promise { try { const result = await context.client.event.subscribe( @@ -1387,7 +592,7 @@ export class OpenCodeServerManager extends EventEmitter { if (context.streamAbortController.signal.aborted) { break; } - this.handleEvent(context, event); + handleEvent(this, context, event); } } catch (cause) { if (context.streamAbortController.signal.aborted) { @@ -1413,595 +618,22 @@ export class OpenCodeServerManager extends EventEmitter { class: "transport_error", }, }); - } - } - - private handleEvent(context: OpenCodeSessionContext, event: OpenCodeEvent): void { - switch (event.type) { - case "session.status": - this.handleSessionStatusEvent(context, event); - return; - case "session.idle": - this.handleSessionIdleEvent(context, event); - return; - case "session.diff": - this.handleSessionDiffEvent(context, event); - return; - case "session.error": - this.handleSessionErrorEvent(context, event); - return; - case "permission.asked": - this.handlePermissionAskedEvent(context, event); - return; - case "permission.replied": - this.handlePermissionRepliedEvent(context, event); - return; - case "question.asked": - this.handleQuestionAskedEvent(context, event); - return; - case "question.replied": - this.handleQuestionRepliedEvent(context, event); - return; - case "question.rejected": - this.handleQuestionRejectedEvent(context, event); - return; - case "message.part.updated": - this.handleMessagePartUpdatedEvent(context, event); - return; - case "message.part.delta": - this.handleMessagePartDeltaEvent(context, event); - return; - case "todo.updated": - this.handleTodoUpdatedEvent(context, event); - return; - } - } - - private handleSessionStatusEvent( - context: OpenCodeSessionContext, - event: EventSessionStatus, - ): void { - const { sessionID: sessionId, status } = event.properties; - if (sessionId !== context.providerSessionId) { - return; - } - const statusType = status.type; - - if (statusType === "busy") { - context.session = { - ...context.session, - status: "running", - updatedAt: nowIso(), - }; this.emitRuntimeEvent({ - type: "session.state.changed", - eventId: eventId("opencode-status-busy"), + type: "session.exited", + eventId: eventId("opencode-session-exited-error"), provider: PROVIDER, threadId: context.threadId, createdAt: nowIso(), - ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), payload: { - state: "running", - }, - raw: { - source: "opencode.server.event", - messageType: statusType, - payload: event, - }, - }); - return; - } - - if (statusType === "retry") { - this.emitRuntimeEvent({ - type: "session.state.changed", - eventId: eventId("opencode-status-retry"), - provider: PROVIDER, - threadId: context.threadId, - createdAt: nowIso(), - ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), - payload: { - state: "waiting", - reason: "retry", - detail: event, - }, - raw: { - source: "opencode.server.event", - messageType: statusType, - payload: event, - }, - }); - return; - } - - if (statusType === "idle") { - const completedAt = nowIso(); - const turnId = context.activeTurnId; - const lastError = context.lastError; - context.activeTurnId = undefined; - context.lastError = undefined; - context.session = { - ...stripTransientSessionFields(context.session), - status: lastError ? "error" : "ready", - updatedAt: completedAt, - ...(lastError ? { lastError } : {}), - }; - - this.emitRuntimeEvent({ - type: "session.state.changed", - eventId: eventId("opencode-status-idle"), - provider: PROVIDER, - threadId: context.threadId, - createdAt: completedAt, - ...(turnId ? { turnId } : {}), - payload: { - state: lastError ? "error" : "ready", - ...(lastError ? { reason: lastError } : {}), - detail: event, - }, - raw: { - source: "opencode.server.event", - messageType: statusType, - payload: event, - }, - }); - - if (turnId) { - this.emitRuntimeEvent({ - type: "turn.completed", - eventId: eventId("opencode-turn-completed"), - provider: PROVIDER, - threadId: context.threadId, - createdAt: completedAt, - turnId, - payload: { - state: lastError ? "failed" : "completed", - ...(lastError ? { errorMessage: lastError } : {}), - }, - raw: { - source: "opencode.server.event", - messageType: statusType, - payload: event, - }, - }); - } - } - } - - private handleSessionIdleEvent(context: OpenCodeSessionContext, event: EventSessionIdle): void { - const { sessionID: sessionId } = event.properties; - if (sessionId !== context.providerSessionId) { - return; - } - - // session.idle is a dedicated completion signal from newer OpenCode versions. - // Treat it identically to session.status with type=idle. - const completedAt = nowIso(); - const turnId = context.activeTurnId; - const lastError = context.lastError; - context.activeTurnId = undefined; - context.lastError = undefined; - context.session = { - ...stripTransientSessionFields(context.session), - status: lastError ? "error" : "ready", - updatedAt: completedAt, - ...(lastError ? { lastError } : {}), - }; - - this.emitRuntimeEvent({ - type: "session.state.changed", - eventId: eventId("opencode-session-idle"), - provider: PROVIDER, - threadId: context.threadId, - createdAt: completedAt, - ...(turnId ? { turnId } : {}), - payload: { - state: lastError ? "error" : "ready", - ...(lastError ? { reason: lastError } : {}), - }, - raw: { - source: "opencode.server.event", - messageType: "session.idle", - payload: event, - }, - }); - - if (turnId) { - this.emitRuntimeEvent({ - type: "turn.completed", - eventId: eventId("opencode-turn-completed-idle"), - provider: PROVIDER, - threadId: context.threadId, - createdAt: completedAt, - turnId, - payload: { - state: lastError ? "failed" : "completed", - ...(lastError ? { errorMessage: lastError } : {}), - }, - raw: { - source: "opencode.server.event", - messageType: "session.idle", - payload: event, - }, - }); - } - } - - private handleSessionDiffEvent(context: OpenCodeSessionContext, _event: EventSessionDiff): void { - const { sessionID: sessionId } = _event.properties; - if (sessionId !== context.providerSessionId) { - return; - } - // session.diff carries per-file change summaries from OpenCode. - // The CheckpointReactor computes diffs via git when turn.completed fires, - // so we don't need to forward this event. It's handled implicitly. - } - - private handleSessionErrorEvent(context: OpenCodeSessionContext, event: EventSessionError): void { - const { sessionID: sessionId, error } = event.properties; - if (sessionId && sessionId !== context.providerSessionId) { - return; - } - const errorMessage = sessionErrorMessage(error) ?? "OpenCode session error"; - context.lastError = errorMessage; - context.session = { - ...stripTransientSessionFields(context.session), - status: "error", - updatedAt: nowIso(), - lastError: errorMessage, - }; - this.emitRuntimeEvent({ - type: "runtime.error", - eventId: eventId("opencode-session-error"), - provider: PROVIDER, - threadId: context.threadId, - createdAt: nowIso(), - ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), - payload: { - message: errorMessage, - class: "provider_error", - }, - raw: { - source: "opencode.server.event", - messageType: "session.error", - payload: event, - }, - }); - } - - private handlePermissionAskedEvent( - context: OpenCodeSessionContext, - event: EventPermissionAsked, - ): void { - const { id: requestIdValue, sessionID: sessionId, permission } = event.properties; - if (sessionId !== context.providerSessionId) { - return; - } - const requestType = toOpencodeRequestType(permission); - const requestId = ApprovalRequestId.makeUnsafe(requestIdValue); - context.pendingPermissions.set(requestId, { requestId, requestType }); - this.emitRuntimeEvent({ - type: "request.opened", - eventId: eventId("opencode-request-opened"), - provider: PROVIDER, - threadId: context.threadId, - createdAt: nowIso(), - ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), - requestId: RuntimeRequestId.makeUnsafe(requestId), - payload: { - requestType, - detail: permission, - args: event.properties, - }, - raw: { - source: "opencode.server.permission", - messageType: "permission.asked", - payload: event, - }, - }); - } - - private handlePermissionRepliedEvent( - context: OpenCodeSessionContext, - event: EventPermissionReplied, - ): void { - const { requestID: requestIdValue, sessionID: sessionId, reply } = event.properties; - if (sessionId !== context.providerSessionId) { - return; - } - const pending = context.pendingPermissions.get(requestIdValue); - context.pendingPermissions.delete(requestIdValue); - this.emitRuntimeEvent({ - type: "request.resolved", - eventId: eventId("opencode-request-resolved"), - provider: PROVIDER, - threadId: context.threadId, - createdAt: nowIso(), - ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), - requestId: RuntimeRequestId.makeUnsafe(requestIdValue), - payload: { - requestType: pending?.requestType ?? "unknown", - decision: reply, - resolution: event.properties, - }, - raw: { - source: "opencode.server.permission", - messageType: "permission.replied", - payload: event, - }, - }); - } - - private handleQuestionAskedEvent( - context: OpenCodeSessionContext, - event: EventQuestionAsked, - ): void { - const { - id: requestIdValue, - sessionID: sessionId, - questions: askedQuestions, - } = event.properties; - if (sessionId !== context.providerSessionId) { - return; - } - const questions = askedQuestions.map((question: QuestionInfo, index) => ({ - answerIndex: index, - id: `${requestIdValue}:${index}`, - header: question.header, - question: question.question, - options: question.options.map((option) => ({ - label: option.label, - description: option.description, - })), - })); - const runtimeQuestions = questions.map((question) => ({ - id: question.id, - header: question.header, - question: question.question, - options: question.options, - })); - - const requestId = ApprovalRequestId.makeUnsafe(requestIdValue); - context.pendingQuestions.set(requestId, { - requestId, - questionIds: questions.map((question) => question.id), - questions, - }); - this.emitRuntimeEvent({ - type: "user-input.requested", - eventId: eventId("opencode-user-input-requested"), - provider: PROVIDER, - threadId: context.threadId, - createdAt: nowIso(), - ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), - requestId: RuntimeRequestId.makeUnsafe(requestId), - payload: { - questions: runtimeQuestions, - }, - raw: { - source: "opencode.server.question", - messageType: "question.asked", - payload: event, - }, - }); - } - - private handleQuestionRepliedEvent( - context: OpenCodeSessionContext, - event: EventQuestionReplied, - ): void { - const { - requestID: requestIdValue, - sessionID: sessionId, - answers: answerArrays, - } = event.properties; - if (sessionId !== context.providerSessionId) { - return; - } - const pending = context.pendingQuestions.get(requestIdValue); - context.pendingQuestions.delete(requestIdValue); - const answers = Object.fromEntries( - (pending?.questions ?? []).map((question) => { - const answer = answerArrays[question.answerIndex]; - if (!answer) { - return [question.id, ""]; - } - return [question.id, answer.filter((value) => value.length > 0)]; - }), - ); - this.emitRuntimeEvent({ - type: "user-input.resolved", - eventId: eventId("opencode-user-input-resolved"), - provider: PROVIDER, - threadId: context.threadId, - createdAt: nowIso(), - ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), - requestId: RuntimeRequestId.makeUnsafe(requestIdValue), - payload: { - answers, - }, - raw: { - source: "opencode.server.question", - messageType: "question.replied", - payload: event, - }, - }); - } - - private handleQuestionRejectedEvent( - context: OpenCodeSessionContext, - event: EventQuestionRejected, - ): void { - const { requestID: requestIdValue, sessionID: sessionId } = event.properties; - if (sessionId !== context.providerSessionId) { - return; - } - context.pendingQuestions.delete(requestIdValue); - this.emitRuntimeEvent({ - type: "user-input.resolved", - eventId: eventId("opencode-user-input-rejected"), - provider: PROVIDER, - threadId: context.threadId, - createdAt: nowIso(), - ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), - requestId: RuntimeRequestId.makeUnsafe(requestIdValue), - payload: { - answers: {}, - }, - raw: { - source: "opencode.server.question", - messageType: "question.rejected", - payload: event, - }, - }); - } - - private handleMessagePartUpdatedEvent( - context: OpenCodeSessionContext, - event: EventMessagePartUpdated, - ): void { - const { part } = event.properties; - if (part.sessionID !== context.providerSessionId) { - return; - } - if (part.type === "text") { - context.partStreamById.set(part.id, { kind: "text", streamKind: "assistant_text" }); - return; - } - if (part.type === "reasoning") { - context.partStreamById.set(part.id, { kind: "reasoning", streamKind: "reasoning_text" }); - return; - } - - if (part.type === "tool") { - this.handleToolPartUpdatedEvent(context, event, part); - } - } - - private handleToolPartUpdatedEvent( - context: OpenCodeSessionContext, - event: EventMessagePartUpdated, - part: OpenCodeToolPart, - ): void { - const previous = context.partStreamById.get(part.id); - const title = toolStateTitle(part.state); - const detail = toolStateDetail(part.state); - const lifecycleType = toToolLifecycleEventType(previous, part.state.status); - - context.partStreamById.set(part.id, { kind: "tool" }); - this.emitRuntimeEvent({ - type: lifecycleType, - eventId: eventId(`opencode-tool-${lifecycleType.replace(".", "-")}`), - provider: PROVIDER, - threadId: context.threadId, - createdAt: nowIso(), - ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), - itemId: RuntimeItemId.makeUnsafe(part.id), - payload: { - itemType: toToolItemType(part.tool), - ...(lifecycleType !== "item.updated" - ? { - status: lifecycleType === "item.completed" ? "completed" : "inProgress", - } - : {}), - title: toToolTitle(part.tool), - ...(detail ? { detail } : {}), - data: { - item: part, - }, - }, - raw: { - source: "opencode.server.event", - messageType: "message.part.updated", - payload: event, - }, - }); - - if ((part.state.status === "completed" || part.state.status === "error") && title) { - this.emitRuntimeEvent({ - type: "tool.summary", - eventId: eventId("opencode-tool-summary"), - provider: PROVIDER, - threadId: context.threadId, - createdAt: nowIso(), - ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), - itemId: RuntimeItemId.makeUnsafe(part.id), - payload: { - summary: `${part.tool}: ${title}`, - precedingToolUseIds: [part.id], - }, - raw: { - source: "opencode.server.event", - messageType: "message.part.updated", - payload: event, + reason: message, + exitKind: "error", + recoverable: false, }, }); } } - private handleMessagePartDeltaEvent( - context: OpenCodeSessionContext, - event: EventMessagePartDelta, - ): void { - const { sessionID, partID: partId, delta } = event.properties; - if (sessionID !== context.providerSessionId) { - return; - } - if (!context.activeTurnId || delta.length === 0) { - return; - } - const partState = context.partStreamById.get(partId); - if (partState?.kind === "tool") { - return; - } - this.emitRuntimeEvent({ - type: "content.delta", - eventId: eventId("opencode-content-delta"), - provider: PROVIDER, - threadId: context.threadId, - createdAt: nowIso(), - turnId: context.activeTurnId, - itemId: RuntimeItemId.makeUnsafe(partId), - payload: { - streamKind: partState?.streamKind ?? "assistant_text", - delta, - }, - raw: { - source: "opencode.server.event", - messageType: "message.part.delta", - payload: event, - }, - }); - } - - private handleTodoUpdatedEvent(context: OpenCodeSessionContext, event: EventTodoUpdated): void { - const { sessionID, todos } = event.properties; - if (sessionID !== context.providerSessionId || !context.activeTurnId) { - return; - } - const plan = todos.map((todo) => ({ - step: todo.content, - status: toPlanStepStatus(todo.status), - })); - this.emitRuntimeEvent({ - type: "turn.plan.updated", - eventId: eventId("opencode-plan-updated"), - provider: PROVIDER, - threadId: context.threadId, - createdAt: nowIso(), - turnId: context.activeTurnId, - payload: { - plan, - }, - raw: { - source: "opencode.server.event", - messageType: "todo.updated", - payload: event, - }, - }); - } - - private emitRuntimeEvent(event: OpenCodeProviderRuntimeEvent): void { + emitRuntimeEvent(event: OpenCodeProviderRuntimeEvent): void { this.emit("event", event as unknown as ProviderRuntimeEvent); } } diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index adeb3f8464..8851cc3dfd 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -151,6 +151,21 @@ function runtimeErrorMessageFromEvent(event: ProviderRuntimeEvent): string | und return payloadMessage; } +function runtimeErrorClassLabel(errorClass: string): string | undefined { + switch (errorClass) { + case "provider_error": + return "Provider error"; + case "transport_error": + return "Connection error"; + case "permission_error": + return "Permission error"; + case "validation_error": + return "Validation error"; + default: + return undefined; + } +} + function orchestrationSessionStatusFromRuntimeState( state: "starting" | "running" | "waiting" | "ready" | "interrupted" | "stopped" | "error", ): "starting" | "running" | "ready" | "interrupted" | "stopped" | "error" { @@ -258,15 +273,19 @@ function runtimeEventToActivities( if (!message) { return []; } + const errorClass = runtimePayloadRecord(event)?.class; + const errorClassLabel = + typeof errorClass === "string" ? runtimeErrorClassLabel(errorClass) : undefined; return [ { id: event.eventId, createdAt: event.createdAt, tone: "error", kind: "runtime.error", - summary: "Runtime error", + summary: errorClassLabel ?? "Runtime error", payload: { message: truncateDetail(message), + detail: truncateDetail(message), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index edd65b9e44..c198ad1699 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -13,7 +13,7 @@ import { type ProviderUserInputAnswers, } from "@t3tools/contracts"; import { it, vi } from "@effect/vitest"; -import { Effect, Fiber, Stream } from "effect"; +import { Effect, Stream } from "effect"; import { OpenCodeServerManager } from "../../opencodeServerManager.ts"; import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; @@ -145,7 +145,6 @@ layer("OpenCodeAdapterLive", (it) => { it.effect("forwards manager runtime events through the adapter stream", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; - const eventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); const event = { type: "content.delta", @@ -161,8 +160,13 @@ layer("OpenCodeAdapterLive", (it) => { }, } as unknown as ProviderRuntimeEvent; + // Emit first — the event is buffered in the unbounded queue via the + // listener that was registered during layer construction. manager.emit("event", event); - const received = yield* Fiber.join(eventFiber); + + // Now consume the head. Since the queue already has an item, this + // resolves immediately without a race condition. + const received = yield* Stream.runHead(adapter.streamEvents); assert.equal(received._tag, "Some"); if (received._tag !== "Some") { From 7fd975a680dc0e3087911a449ff7d63c20e10f43 Mon Sep 17 00:00:00 2001 From: sherlock Date: Mon, 16 Mar 2026 09:17:48 +0530 Subject: [PATCH 2/5] style: format opencode adapter files with oxfmt --- apps/server/src/opencode/eventHandlers.ts | 6 +----- apps/server/src/opencode/serverLifecycle.ts | 5 ++++- apps/server/src/opencode/utils.ts | 11 +++++++++-- apps/server/src/opencodeServerManager.ts | 5 ++++- 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/apps/server/src/opencode/eventHandlers.ts b/apps/server/src/opencode/eventHandlers.ts index b4fc5b69b5..1b82290fe0 100644 --- a/apps/server/src/opencode/eventHandlers.ts +++ b/apps/server/src/opencode/eventHandlers.ts @@ -406,11 +406,7 @@ function handleQuestionAskedEvent( context: OpenCodeSessionContext, event: EventQuestionAsked, ): void { - const { - id: requestIdValue, - sessionID: sessionId, - questions: askedQuestions, - } = event.properties; + const { id: requestIdValue, sessionID: sessionId, questions: askedQuestions } = event.properties; if (sessionId !== context.providerSessionId) { return; } diff --git a/apps/server/src/opencode/serverLifecycle.ts b/apps/server/src/opencode/serverLifecycle.ts index 1a05fb1f2d..5c98e02215 100644 --- a/apps/server/src/opencode/serverLifecycle.ts +++ b/apps/server/src/opencode/serverLifecycle.ts @@ -40,7 +40,10 @@ export async function createClient(options: OpencodeClientOptions): Promise | undefined }, + cached: { + server: SharedServerState | undefined; + serverPromise: Promise | undefined; + }, ): Promise<{ state: SharedServerState; serverPromise: Promise | undefined; diff --git a/apps/server/src/opencode/utils.ts b/apps/server/src/opencode/utils.ts index 5d6fd3a6f9..0d11ee839b 100644 --- a/apps/server/src/opencode/utils.ts +++ b/apps/server/src/opencode/utils.ts @@ -1,6 +1,11 @@ import { randomUUID } from "node:crypto"; -import { EventId, TurnId, type CanonicalRequestType, type ProviderApprovalDecision } from "@t3tools/contracts"; +import { + EventId, + TurnId, + type CanonicalRequestType, + type ProviderApprovalDecision, +} from "@t3tools/contracts"; import type { ConfigProvidersResponse, @@ -197,7 +202,9 @@ export function toOpencodeRequestType(permission: string | undefined): Canonical } } -export function toPermissionReply(decision: ProviderApprovalDecision): "once" | "always" | "reject" { +export function toPermissionReply( + decision: ProviderApprovalDecision, +): "once" | "always" | "reject" { switch (decision) { case "acceptForSession": return "always"; diff --git a/apps/server/src/opencodeServerManager.ts b/apps/server/src/opencodeServerManager.ts index 928693675a..b8fddae3c3 100644 --- a/apps/server/src/opencodeServerManager.ts +++ b/apps/server/src/opencodeServerManager.ts @@ -47,7 +47,10 @@ import { import { handleEvent } from "./opencode/eventHandlers.ts"; import { createClient, ensureServer } from "./opencode/serverLifecycle.ts"; -export { type OpenCodeDiscoveredModel, type OpenCodeModelDiscoveryOptions } from "./opencode/types.ts"; +export { + type OpenCodeDiscoveredModel, + type OpenCodeModelDiscoveryOptions, +} from "./opencode/types.ts"; export class OpenCodeServerManager extends EventEmitter { private readonly sessions = new Map(); From 540a3efa92b24a80b8299a1db0d21b5c11ebc1b7 Mon Sep 17 00:00:00 2001 From: sherlock Date: Mon, 16 Mar 2026 09:23:54 +0530 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20use=20UUID=20for=20command=20itemId,=20fix=20ensure?= =?UTF-8?q?Server=20catch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace Date.now() with randomUUID() in command.executed itemId to prevent millisecond-level collisions - Fix ensureServer catch block that re-awaited the rejected promise instead of propagating the error for retry --- apps/server/src/opencode/eventHandlers.ts | 4 +++- apps/server/src/opencode/serverLifecycle.ts | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/server/src/opencode/eventHandlers.ts b/apps/server/src/opencode/eventHandlers.ts index 1b82290fe0..5a56a3ab92 100644 --- a/apps/server/src/opencode/eventHandlers.ts +++ b/apps/server/src/opencode/eventHandlers.ts @@ -1,3 +1,5 @@ +import { randomUUID } from "node:crypto"; + import { ApprovalRequestId, RuntimeItemId, RuntimeRequestId } from "@t3tools/contracts"; import { sessionErrorClass, sessionErrorIsRetryable, sessionErrorMessage } from "./errors.ts"; @@ -807,7 +809,7 @@ function handleCommandExecutedEvent( if (sessionID !== context.providerSessionId) { return; } - const itemId = RuntimeItemId.makeUnsafe(`cmd:${command}:${Date.now()}`); + const itemId = RuntimeItemId.makeUnsafe(`cmd:${command}:${randomUUID()}`); const title = `Command: ${command}`; emitter.emitRuntimeEvent({ type: "item.started", diff --git a/apps/server/src/opencode/serverLifecycle.ts b/apps/server/src/opencode/serverLifecycle.ts index 5c98e02215..9424365cd3 100644 --- a/apps/server/src/opencode/serverLifecycle.ts +++ b/apps/server/src/opencode/serverLifecycle.ts @@ -60,8 +60,10 @@ export async function ensureServer( try { const state = await serverPromise; return { state, serverPromise }; - } catch { - return { state: await serverPromise, serverPromise: undefined }; + } catch (error) { + // Clear the promise so next call will retry instead of re-awaiting the + // same rejected promise. + throw error; } } From 252d23d98564c62a130dd87f4eaf4dc08053810c Mon Sep 17 00:00:00 2001 From: sherlock Date: Mon, 16 Mar 2026 09:32:48 +0530 Subject: [PATCH 4/5] feat: replicate OpenCode adapter parity improvements to Kilo adapter Kilo is a fork of OpenCode sharing the same SDK and SSE API. Apply the same modular extraction and feature parity improvements: - Extract monolithic kiloServerManager.ts into kilo/ module directory (types, errors, utils, eventHandlers, serverLifecycle) - Emit session.configured and session.exited lifecycle events - Forward session.diff as turn.diff.updated with unified diff conversion - Handle new SSE events: session.compacted, session.updated, vcs.branch.updated, file.edited, command.executed, message.part.removed - Enrich events: todo priority, isRetryable, permission title/metadata - Implement rollbackThread via session.revert API with message ID tracking - Fix flaky KiloAdapter stream forwarding test (same race condition fix) --- apps/server/src/kilo/errors.ts | 111 ++ apps/server/src/kilo/eventHandlers.ts | 854 +++++++++ apps/server/src/kilo/index.ts | 5 + apps/server/src/kilo/serverLifecycle.ts | 169 ++ apps/server/src/kilo/types.ts | 598 ++++++ apps/server/src/kilo/utils.ts | 388 ++++ apps/server/src/kiloServerManager.ts | 1601 ++--------------- .../src/provider/Layers/KiloAdapter.test.ts | 10 +- 8 files changed, 2311 insertions(+), 1425 deletions(-) create mode 100644 apps/server/src/kilo/errors.ts create mode 100644 apps/server/src/kilo/eventHandlers.ts create mode 100644 apps/server/src/kilo/index.ts create mode 100644 apps/server/src/kilo/serverLifecycle.ts create mode 100644 apps/server/src/kilo/types.ts create mode 100644 apps/server/src/kilo/utils.ts diff --git a/apps/server/src/kilo/errors.ts b/apps/server/src/kilo/errors.ts new file mode 100644 index 0000000000..0989c9c825 --- /dev/null +++ b/apps/server/src/kilo/errors.ts @@ -0,0 +1,111 @@ +import type { EventSessionError } from "./types.ts"; + +/** + * Maps an Kilo error name to a runtime error class used by the + * orchestration layer to categorize errors for display. + */ +export function sessionErrorClass( + errorName: string | undefined, +): "provider_error" | "transport_error" | "permission_error" | "validation_error" | "unknown" { + switch (errorName) { + case "ProviderAuthError": + return "permission_error"; + case "APIError": + case "ContextOverflowError": + case "MessageOutputLengthError": + case "StructuredOutputError": + return "provider_error"; + case "MessageAbortedError": + return "transport_error"; + case "UnknownError": + default: + return "unknown"; + } +} + +/** + * Returns a human-readable label for the Kilo error name. + */ +export function sessionErrorLabel(errorName: string): string { + switch (errorName) { + case "ProviderAuthError": + return "Authentication failed"; + case "UnknownError": + return "Unknown error"; + case "MessageAbortedError": + return "Message aborted"; + case "StructuredOutputError": + return "Structured output error"; + case "ContextOverflowError": + return "Context window exceeded"; + case "APIError": + return "API error"; + case "MessageOutputLengthError": + return "Response exceeded output length"; + default: + return errorName; + } +} + +/** + * Returns whether an Kilo error is retryable, if the information is + * available (currently only `APIError` carries `isRetryable`). + */ +export function sessionErrorIsRetryable( + error: EventSessionError["properties"]["error"], +): boolean | undefined { + if (!error) { + return undefined; + } + if (error.name === "APIError") { + const data = error.data as Record | undefined; + return typeof data?.isRetryable === "boolean" ? data.isRetryable : undefined; + } + return undefined; +} + +/** + * Extracts a human-readable error message from an Kilo `session.error` + * event, combining the error label with any detail from the payload. + * + * Each Kilo error type has a specific `data` shape (from the SDK): + * - ProviderAuthError: { providerID, message } + * - UnknownError: { message } + * - MessageAbortedError: { message } + * - StructuredOutputError: { message, retries } + * - ContextOverflowError: { message, responseBody? } + * - APIError: { message, statusCode?, isRetryable, responseHeaders?, responseBody?, metadata? } + * - MessageOutputLengthError: { [key: string]: unknown } + */ +export function sessionErrorMessage( + error: EventSessionError["properties"]["error"], +): string | undefined { + if (!error) { + return undefined; + } + + const data = error.data as Record | undefined; + const label = sessionErrorLabel(error.name); + const detail = typeof data?.message === "string" ? data.message : undefined; + + switch (error.name) { + case "ProviderAuthError": { + const providerID = typeof data?.providerID === "string" ? data.providerID : undefined; + const prefix = providerID ? `${label} (${providerID})` : label; + return detail ? `${prefix}: ${detail}` : prefix; + } + case "APIError": { + const statusCode = typeof data?.statusCode === "number" ? data.statusCode : undefined; + const prefix = statusCode ? `${label} ${statusCode}` : label; + return detail ? `${prefix}: ${detail}` : prefix; + } + case "StructuredOutputError": { + const retries = typeof data?.retries === "number" ? data.retries : undefined; + const suffix = retries != null ? ` (after ${retries} retries)` : ""; + return detail ? `${label}: ${detail}${suffix}` : `${label}${suffix}`; + } + default: { + return detail ? `${label}: ${detail}` : label; + } + } +} diff --git a/apps/server/src/kilo/eventHandlers.ts b/apps/server/src/kilo/eventHandlers.ts new file mode 100644 index 0000000000..cbaf9b1308 --- /dev/null +++ b/apps/server/src/kilo/eventHandlers.ts @@ -0,0 +1,854 @@ +import { randomUUID } from "node:crypto"; + +import { ApprovalRequestId, RuntimeItemId, RuntimeRequestId } from "@t3tools/contracts"; + +import { sessionErrorClass, sessionErrorIsRetryable, sessionErrorMessage } from "./errors.ts"; +import type { + EventCommandExecuted, + EventFileEdited, + EventMessagePartDelta, + EventMessagePartUpdated, + EventPermissionAsked, + EventPermissionReplied, + EventQuestionAsked, + EventQuestionRejected, + EventQuestionReplied, + EventSessionCompacted, + EventSessionDiff, + EventSessionError, + EventSessionIdle, + EventSessionStatus, + EventSessionUpdated, + EventTodoUpdated, + EventVcsBranchUpdated, + KiloEvent, + KiloProviderRuntimeEvent, + KiloSessionContext, + KiloToolPart, + QuestionInfo, +} from "./types.ts"; +import { PROVIDER } from "./types.ts"; +import { + eventId, + fileDiffsToUnifiedDiff, + nowIso, + stripTransientSessionFields, + todoPriorityPrefix, + toKiloRequestType, + toPlanStepStatus, + toToolItemType, + toToolLifecycleEventType, + toToolTitle, + toolStateDetail, + toolStateTitle, +} from "./utils.ts"; + +type EventEmitter = { + emitRuntimeEvent(event: KiloProviderRuntimeEvent): void; +}; + +/** + * Dispatches an Kilo SSE event to the appropriate handler. + */ +export function handleEvent( + emitter: EventEmitter, + context: KiloSessionContext, + event: KiloEvent, +): void { + switch (event.type) { + case "session.status": + handleSessionStatusEvent(emitter, context, event); + return; + case "session.idle": + handleSessionIdleEvent(emitter, context, event); + return; + case "session.diff": + handleSessionDiffEvent(emitter, context, event); + return; + case "session.error": + handleSessionErrorEvent(emitter, context, event); + return; + case "session.compacted": + handleSessionCompactedEvent(emitter, context, event); + return; + case "session.updated": + handleSessionUpdatedEvent(emitter, context, event); + return; + case "permission.asked": + handlePermissionAskedEvent(emitter, context, event); + return; + case "permission.replied": + handlePermissionRepliedEvent(emitter, context, event); + return; + case "question.asked": + handleQuestionAskedEvent(emitter, context, event); + return; + case "question.replied": + handleQuestionRepliedEvent(emitter, context, event); + return; + case "question.rejected": + handleQuestionRejectedEvent(emitter, context, event); + return; + case "message.part.updated": + handleMessagePartUpdatedEvent(emitter, context, event); + return; + case "message.part.delta": + handleMessagePartDeltaEvent(emitter, context, event); + return; + case "message.part.removed": + // Silently ignored — prevents "unknown event" issues if logging is added later. + return; + case "todo.updated": + handleTodoUpdatedEvent(emitter, context, event); + return; + case "vcs.branch.updated": + handleVcsBranchUpdatedEvent(emitter, context, event); + return; + case "file.edited": + handleFileEditedEvent(emitter, context, event); + return; + case "command.executed": + handleCommandExecutedEvent(emitter, context, event); + return; + } +} + +// --------------------------------------------------------------------------- +// Session status / lifecycle +// --------------------------------------------------------------------------- + +function handleSessionStatusEvent( + emitter: EventEmitter, + context: KiloSessionContext, + event: EventSessionStatus, +): void { + const { sessionID: sessionId, status } = event.properties; + if (sessionId !== context.providerSessionId) { + return; + } + const statusType = status.type; + + if (statusType === "busy") { + context.session = { + ...context.session, + status: "running", + updatedAt: nowIso(), + }; + emitter.emitRuntimeEvent({ + type: "session.state.changed", + eventId: eventId("kilo-status-busy"), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), + payload: { + state: "running", + }, + raw: { + source: "kilo.server.event", + messageType: statusType, + payload: event, + }, + }); + return; + } + + if (statusType === "retry") { + emitter.emitRuntimeEvent({ + type: "session.state.changed", + eventId: eventId("kilo-status-retry"), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), + payload: { + state: "waiting", + reason: "retry", + detail: event, + }, + raw: { + source: "kilo.server.event", + messageType: statusType, + payload: event, + }, + }); + return; + } + + if (statusType === "idle") { + completeTurn(emitter, context, "kilo-status-idle", "kilo-turn-completed", event); + } +} + +function handleSessionIdleEvent( + emitter: EventEmitter, + context: KiloSessionContext, + event: EventSessionIdle, +): void { + const { sessionID: sessionId } = event.properties; + if (sessionId !== context.providerSessionId) { + return; + } + completeTurn(emitter, context, "kilo-session-idle", "kilo-turn-completed-idle", event); +} + +/** + * Shared logic for completing a turn when session goes idle (via either + * `session.status` with type=idle or the dedicated `session.idle` event). + */ +function completeTurn( + emitter: EventEmitter, + context: KiloSessionContext, + stateEventPrefix: string, + turnEventPrefix: string, + event: EventSessionStatus | EventSessionIdle, +): void { + const completedAt = nowIso(); + const turnId = context.activeTurnId; + const lastError = context.lastError; + context.activeTurnId = undefined; + context.lastError = undefined; + context.session = { + ...stripTransientSessionFields(context.session), + status: lastError ? "error" : "ready", + updatedAt: completedAt, + ...(lastError ? { lastError } : {}), + }; + + const messageType = + event.type === "session.idle" + ? "session.idle" + : (event as EventSessionStatus).properties.status.type; + + emitter.emitRuntimeEvent({ + type: "session.state.changed", + eventId: eventId(stateEventPrefix), + provider: PROVIDER, + threadId: context.threadId, + createdAt: completedAt, + ...(turnId ? { turnId } : {}), + payload: { + state: lastError ? "error" : "ready", + ...(lastError ? { reason: lastError } : {}), + ...(event.type !== "session.idle" ? { detail: event } : {}), + }, + raw: { + source: "kilo.server.event", + messageType, + payload: event, + }, + }); + + if (turnId) { + emitter.emitRuntimeEvent({ + type: "turn.completed", + eventId: eventId(turnEventPrefix), + provider: PROVIDER, + threadId: context.threadId, + createdAt: completedAt, + turnId, + payload: { + state: lastError ? "failed" : "completed", + ...(lastError ? { errorMessage: lastError } : {}), + }, + raw: { + source: "kilo.server.event", + messageType, + payload: event, + }, + }); + } +} + +function handleSessionDiffEvent( + emitter: EventEmitter, + context: KiloSessionContext, + event: EventSessionDiff, +): void { + const { sessionID: sessionId, diff } = event.properties; + if (sessionId !== context.providerSessionId) { + return; + } + if (!context.activeTurnId || !diff || diff.length === 0) { + return; + } + const unifiedDiff = fileDiffsToUnifiedDiff(diff); + emitter.emitRuntimeEvent({ + type: "turn.diff.updated", + eventId: eventId("kilo-turn-diff-updated"), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + turnId: context.activeTurnId, + payload: { + unifiedDiff, + }, + raw: { + source: "kilo.server.event", + messageType: "session.diff", + payload: event, + }, + }); +} + +function handleSessionErrorEvent( + emitter: EventEmitter, + context: KiloSessionContext, + event: EventSessionError, +): void { + const { sessionID: sessionId, error } = event.properties; + if (sessionId && sessionId !== context.providerSessionId) { + return; + } + const errorMessage = sessionErrorMessage(error) ?? "Kilo session error"; + const errorClass = sessionErrorClass(error?.name); + const isRetryable = sessionErrorIsRetryable(error); + context.lastError = errorMessage; + context.session = { + ...stripTransientSessionFields(context.session), + status: "error", + updatedAt: nowIso(), + lastError: errorMessage, + }; + emitter.emitRuntimeEvent({ + type: "runtime.error", + eventId: eventId("kilo-session-error"), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), + payload: { + message: errorMessage, + class: errorClass, + ...(isRetryable != null ? { detail: { isRetryable } } : {}), + }, + raw: { + source: "kilo.server.event", + messageType: "session.error", + payload: event, + }, + }); +} + +// --------------------------------------------------------------------------- +// Permission events +// --------------------------------------------------------------------------- + +function handlePermissionAskedEvent( + emitter: EventEmitter, + context: KiloSessionContext, + event: EventPermissionAsked, +): void { + const { id: requestIdValue, sessionID: sessionId, permission, title } = event.properties; + if (sessionId !== context.providerSessionId) { + return; + } + const requestType = toKiloRequestType(permission); + const requestId = ApprovalRequestId.makeUnsafe(requestIdValue); + context.pendingPermissions.set(requestId, { requestId, requestType }); + emitter.emitRuntimeEvent({ + type: "request.opened", + eventId: eventId("kilo-request-opened"), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), + requestId: RuntimeRequestId.makeUnsafe(requestId), + payload: { + requestType, + detail: title ?? permission, + args: event.properties, + }, + raw: { + source: "kilo.server.permission", + messageType: "permission.asked", + payload: event, + }, + }); +} + +function handlePermissionRepliedEvent( + emitter: EventEmitter, + context: KiloSessionContext, + event: EventPermissionReplied, +): void { + const { requestID: requestIdValue, sessionID: sessionId, reply } = event.properties; + if (sessionId !== context.providerSessionId) { + return; + } + const pending = context.pendingPermissions.get(requestIdValue); + context.pendingPermissions.delete(requestIdValue); + emitter.emitRuntimeEvent({ + type: "request.resolved", + eventId: eventId("kilo-request-resolved"), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), + requestId: RuntimeRequestId.makeUnsafe(requestIdValue), + payload: { + requestType: pending?.requestType ?? "unknown", + decision: reply, + resolution: event.properties, + }, + raw: { + source: "kilo.server.permission", + messageType: "permission.replied", + payload: event, + }, + }); +} + +// --------------------------------------------------------------------------- +// Question events +// --------------------------------------------------------------------------- + +function handleQuestionAskedEvent( + emitter: EventEmitter, + context: KiloSessionContext, + event: EventQuestionAsked, +): void { + const { id: requestIdValue, sessionID: sessionId, questions: askedQuestions } = event.properties; + if (sessionId !== context.providerSessionId) { + return; + } + const questions = askedQuestions.map((question: QuestionInfo, index) => ({ + answerIndex: index, + id: `${requestIdValue}:${index}`, + header: question.header, + question: question.question, + options: question.options.map((option) => ({ + label: option.label, + description: option.description, + })), + })); + const runtimeQuestions = questions.map((question) => ({ + id: question.id, + header: question.header, + question: question.question, + options: question.options, + })); + + const requestId = ApprovalRequestId.makeUnsafe(requestIdValue); + context.pendingQuestions.set(requestId, { + requestId, + questionIds: questions.map((question) => question.id), + questions, + }); + emitter.emitRuntimeEvent({ + type: "user-input.requested", + eventId: eventId("kilo-user-input-requested"), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), + requestId: RuntimeRequestId.makeUnsafe(requestId), + payload: { + questions: runtimeQuestions, + }, + raw: { + source: "kilo.server.question", + messageType: "question.asked", + payload: event, + }, + }); +} + +function handleQuestionRepliedEvent( + emitter: EventEmitter, + context: KiloSessionContext, + event: EventQuestionReplied, +): void { + const { + requestID: requestIdValue, + sessionID: sessionId, + answers: answerArrays, + } = event.properties; + if (sessionId !== context.providerSessionId) { + return; + } + const pending = context.pendingQuestions.get(requestIdValue); + context.pendingQuestions.delete(requestIdValue); + const answers = Object.fromEntries( + (pending?.questions ?? []).map((question) => { + const answer = answerArrays[question.answerIndex]; + if (!answer) { + return [question.id, ""]; + } + return [question.id, answer.filter((value) => value.length > 0)]; + }), + ); + emitter.emitRuntimeEvent({ + type: "user-input.resolved", + eventId: eventId("kilo-user-input-resolved"), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), + requestId: RuntimeRequestId.makeUnsafe(requestIdValue), + payload: { + answers, + }, + raw: { + source: "kilo.server.question", + messageType: "question.replied", + payload: event, + }, + }); +} + +function handleQuestionRejectedEvent( + emitter: EventEmitter, + context: KiloSessionContext, + event: EventQuestionRejected, +): void { + const { requestID: requestIdValue, sessionID: sessionId } = event.properties; + if (sessionId !== context.providerSessionId) { + return; + } + context.pendingQuestions.delete(requestIdValue); + emitter.emitRuntimeEvent({ + type: "user-input.resolved", + eventId: eventId("kilo-user-input-rejected"), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), + requestId: RuntimeRequestId.makeUnsafe(requestIdValue), + payload: { + answers: {}, + }, + raw: { + source: "kilo.server.question", + messageType: "question.rejected", + payload: event, + }, + }); +} + +// --------------------------------------------------------------------------- +// Message part events (text, reasoning, tool) +// --------------------------------------------------------------------------- + +function handleMessagePartUpdatedEvent( + emitter: EventEmitter, + context: KiloSessionContext, + event: EventMessagePartUpdated, +): void { + const { part } = event.properties; + if (part.sessionID !== context.providerSessionId) { + return; + } + // Track message IDs for rollback support (Tier 4a) + if (part.messageID && !context.messageIds.includes(part.messageID)) { + context.messageIds.push(part.messageID); + } + if (part.type === "text") { + context.partStreamById.set(part.id, { kind: "text", streamKind: "assistant_text" }); + return; + } + if (part.type === "reasoning") { + context.partStreamById.set(part.id, { kind: "reasoning", streamKind: "reasoning_text" }); + return; + } + + if (part.type === "tool") { + handleToolPartUpdatedEvent(emitter, context, event, part); + } +} + +function handleToolPartUpdatedEvent( + emitter: EventEmitter, + context: KiloSessionContext, + event: EventMessagePartUpdated, + part: KiloToolPart, +): void { + const previous = context.partStreamById.get(part.id); + const title = toolStateTitle(part.state); + const detail = toolStateDetail(part.state); + const lifecycleType = toToolLifecycleEventType(previous, part.state.status); + + context.partStreamById.set(part.id, { kind: "tool" }); + emitter.emitRuntimeEvent({ + type: lifecycleType, + eventId: eventId(`kilo-tool-${lifecycleType.replace(".", "-")}`), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), + itemId: RuntimeItemId.makeUnsafe(part.id), + payload: { + itemType: toToolItemType(part.tool), + ...(lifecycleType !== "item.updated" + ? { + status: lifecycleType === "item.completed" ? "completed" : "inProgress", + } + : {}), + title: toToolTitle(part.tool), + ...(detail ? { detail } : {}), + data: { + item: part, + }, + }, + raw: { + source: "kilo.server.event", + messageType: "message.part.updated", + payload: event, + }, + }); + + if ((part.state.status === "completed" || part.state.status === "error") && title) { + emitter.emitRuntimeEvent({ + type: "tool.summary", + eventId: eventId("kilo-tool-summary"), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), + itemId: RuntimeItemId.makeUnsafe(part.id), + payload: { + summary: `${part.tool}: ${title}`, + precedingToolUseIds: [part.id], + }, + raw: { + source: "kilo.server.event", + messageType: "message.part.updated", + payload: event, + }, + }); + } +} + +function handleMessagePartDeltaEvent( + emitter: EventEmitter, + context: KiloSessionContext, + event: EventMessagePartDelta, +): void { + const { sessionID, partID: partId, delta } = event.properties; + if (sessionID !== context.providerSessionId) { + return; + } + if (!context.activeTurnId || delta.length === 0) { + return; + } + const partState = context.partStreamById.get(partId); + if (partState?.kind === "tool") { + return; + } + emitter.emitRuntimeEvent({ + type: "content.delta", + eventId: eventId("kilo-content-delta"), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + turnId: context.activeTurnId, + itemId: RuntimeItemId.makeUnsafe(partId), + payload: { + streamKind: partState?.streamKind ?? "assistant_text", + delta, + }, + raw: { + source: "kilo.server.event", + messageType: "message.part.delta", + payload: event, + }, + }); +} + +// --------------------------------------------------------------------------- +// Todo / plan events +// --------------------------------------------------------------------------- + +function handleTodoUpdatedEvent( + emitter: EventEmitter, + context: KiloSessionContext, + event: EventTodoUpdated, +): void { + const { sessionID, todos } = event.properties; + if (sessionID !== context.providerSessionId || !context.activeTurnId) { + return; + } + const plan = todos.map((todo) => ({ + step: todoPriorityPrefix(todo), + status: toPlanStepStatus(todo.status), + })); + emitter.emitRuntimeEvent({ + type: "turn.plan.updated", + eventId: eventId("kilo-plan-updated"), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + turnId: context.activeTurnId, + payload: { + plan, + }, + raw: { + source: "kilo.server.event", + messageType: "todo.updated", + payload: event, + }, + }); +} + +// --------------------------------------------------------------------------- +// Tier 2 — New SSE event handlers +// --------------------------------------------------------------------------- + +function handleSessionCompactedEvent( + emitter: EventEmitter, + context: KiloSessionContext, + event: EventSessionCompacted, +): void { + const { sessionID } = event.properties; + if (sessionID !== context.providerSessionId) { + return; + } + emitter.emitRuntimeEvent({ + type: "thread.state.changed", + eventId: eventId("kilo-session-compacted"), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), + payload: { + state: "compacted", + }, + raw: { + source: "kilo.server.event", + messageType: "session.compacted", + payload: event, + }, + }); +} + +function handleSessionUpdatedEvent( + emitter: EventEmitter, + context: KiloSessionContext, + event: EventSessionUpdated, +): void { + const { sessionID, info } = event.properties; + if (sessionID !== context.providerSessionId) { + return; + } + emitter.emitRuntimeEvent({ + type: "thread.metadata.updated", + eventId: eventId("kilo-session-updated"), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), + payload: { + ...(info?.title ? { name: info.title } : {}), + metadata: info ?? {}, + }, + raw: { + source: "kilo.server.event", + messageType: "session.updated", + payload: event, + }, + }); +} + +function handleVcsBranchUpdatedEvent( + emitter: EventEmitter, + context: KiloSessionContext, + event: EventVcsBranchUpdated, +): void { + emitter.emitRuntimeEvent({ + type: "thread.metadata.updated", + eventId: eventId("kilo-vcs-branch-updated"), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), + payload: { + metadata: { branch: event.properties.branch }, + }, + raw: { + source: "kilo.server.event", + messageType: "vcs.branch.updated", + payload: event, + }, + }); +} + +function handleFileEditedEvent( + emitter: EventEmitter, + context: KiloSessionContext, + event: EventFileEdited, +): void { + emitter.emitRuntimeEvent({ + type: "files.persisted", + eventId: eventId("kilo-file-edited"), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), + payload: { + files: [ + { + filename: event.properties.filename, + fileId: event.properties.fileId ?? event.properties.filename, + }, + ], + }, + raw: { + source: "kilo.server.event", + messageType: "file.edited", + payload: event, + }, + }); +} + +function handleCommandExecutedEvent( + emitter: EventEmitter, + context: KiloSessionContext, + event: EventCommandExecuted, +): void { + const { sessionID, command } = event.properties; + if (sessionID !== context.providerSessionId) { + return; + } + const itemId = RuntimeItemId.makeUnsafe(`cmd:${command}:${randomUUID()}`); + const title = `Command: ${command}`; + emitter.emitRuntimeEvent({ + type: "item.started", + eventId: eventId("kilo-command-started"), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), + itemId, + payload: { + itemType: "dynamic_tool_call", + status: "inProgress", + title, + data: { item: event.properties }, + }, + raw: { + source: "kilo.server.event", + messageType: "command.executed", + payload: event, + }, + }); + emitter.emitRuntimeEvent({ + type: "item.completed", + eventId: eventId("kilo-command-completed"), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), + itemId, + payload: { + itemType: "dynamic_tool_call", + status: "completed", + title, + data: { item: event.properties }, + }, + raw: { + source: "kilo.server.event", + messageType: "command.executed", + payload: event, + }, + }); +} diff --git a/apps/server/src/kilo/index.ts b/apps/server/src/kilo/index.ts new file mode 100644 index 0000000000..6c01f8ea13 --- /dev/null +++ b/apps/server/src/kilo/index.ts @@ -0,0 +1,5 @@ +export * from "./types.ts"; +export * from "./errors.ts"; +export * from "./utils.ts"; +export * from "./eventHandlers.ts"; +export * from "./serverLifecycle.ts"; diff --git a/apps/server/src/kilo/serverLifecycle.ts b/apps/server/src/kilo/serverLifecycle.ts new file mode 100644 index 0000000000..021fcb9bd3 --- /dev/null +++ b/apps/server/src/kilo/serverLifecycle.ts @@ -0,0 +1,169 @@ +import { spawn } from "node:child_process"; + +import { + DEFAULT_HOSTNAME, + DEFAULT_PORT, + SERVER_PROBE_TIMEOUT_MS, + SERVER_START_TIMEOUT_MS, + type KiloProviderOptions, + type KiloClient, + type OpencodeClientOptions, + type KiloSdkModule, + type SharedServerState, +} from "./types.ts"; +import { buildAuthHeader, parseServerUrl } from "./utils.ts"; + +/** + * Probes the Kilo server health endpoint to check if it's running. + */ +export async function probeServer(baseUrl: string, authHeader?: string): Promise { + const response = await fetch(`${baseUrl}/global/health`, { + method: "GET", + ...(authHeader ? { headers: { Authorization: authHeader } } : {}), + signal: AbortSignal.timeout(SERVER_PROBE_TIMEOUT_MS), + }).catch(() => undefined); + return response?.ok === true; +} + +/** + * Creates a Kilo SDK client by dynamically importing the OpenCode SDK. + * Kilo is a fork of OpenCode and exposes the same HTTP+SSE API. + */ +export async function createClient(options: OpencodeClientOptions): Promise { + const sdkModuleId = "@opencode-ai/sdk/v2/client"; + const sdk = (await import(sdkModuleId)) as KiloSdkModule; + return sdk.createOpencodeClient(options); +} + +/** + * Ensures a Kilo server is running, either by connecting to an existing + * one or spawning a new process. Returns the shared server state. + */ +export async function ensureServer( + options: KiloProviderOptions | undefined, + cached: { + server: SharedServerState | undefined; + serverPromise: Promise | undefined; + }, +): Promise<{ + state: SharedServerState; + serverPromise: Promise | undefined; +}> { + if (cached.server) { + return { state: cached.server, serverPromise: cached.serverPromise }; + } + if (cached.serverPromise) { + const state = await cached.serverPromise; + return { state, serverPromise: cached.serverPromise }; + } + + const serverPromise = spawnOrConnect(options); + try { + const state = await serverPromise; + return { state, serverPromise }; + } catch (error) { + // Clear the promise so next call will retry instead of re-awaiting the + // same rejected promise. + throw error; + } +} + +async function spawnOrConnect(options?: KiloProviderOptions): Promise { + const authHeader = buildAuthHeader(options?.username, options?.password); + + if (options?.serverUrl) { + return { + baseUrl: options.serverUrl, + ...(authHeader ? { authHeader } : {}), + }; + } + + const hostname = options?.hostname ?? DEFAULT_HOSTNAME; + const port = Math.trunc(options?.port ?? DEFAULT_PORT); + const baseUrl = `http://${hostname}:${port}`; + const healthy = await probeServer(baseUrl, authHeader); + if (healthy) { + return { + baseUrl, + ...(authHeader ? { authHeader } : {}), + }; + } + + const binaryPath = options?.binaryPath ?? "kilo"; + const child = spawn(binaryPath, ["serve", `--hostname=${hostname}`, `--port=${port}`], { + env: { + ...process.env, + ...(options?.username ? { KILO_SERVER_USERNAME: options.username } : {}), + ...(options?.password ? { KILO_SERVER_PASSWORD: options.password } : {}), + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + const startedBaseUrl = await new Promise((resolve, reject) => { + let output = ""; + + const onChunk = (chunk: Buffer) => { + output += chunk.toString(); + const url = parseServerUrl(output); + if (!url) { + return; + } + cleanup(); + resolve(url); + }; + + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + + const onExit = (code: number | null) => { + cleanup(); + void probeServer(baseUrl, authHeader).then((reuse) => { + if (reuse) { + resolve(baseUrl); + return; + } + const detail = output.trim().replaceAll(/\s+/g, " ").slice(0, 400); + reject( + new Error( + `Kilo server exited before startup completed (code ${code})${ + detail.length > 0 ? `: ${detail}` : "" + }`, + ), + ); + }); + }; + + const cleanup = () => { + clearTimeout(timeout); + child.stdout.off("data", onChunk); + child.stderr.off("data", onChunk); + child.off("error", onError); + child.off("exit", onExit); + }; + + const timeout = setTimeout(() => { + cleanup(); + try { + child.kill(); + } catch { + // Process may already be dead. + } + reject( + new Error(`Timed out waiting for Kilo server to start after ${SERVER_START_TIMEOUT_MS}ms`), + ); + }, SERVER_START_TIMEOUT_MS); + + child.stdout.on("data", onChunk); + child.stderr.on("data", onChunk); + child.once("error", onError); + child.once("exit", onExit); + }); + + return { + baseUrl: startedBaseUrl, + child, + ...(authHeader ? { authHeader } : {}), + }; +} diff --git a/apps/server/src/kilo/types.ts b/apps/server/src/kilo/types.ts new file mode 100644 index 0000000000..73796dd2b4 --- /dev/null +++ b/apps/server/src/kilo/types.ts @@ -0,0 +1,598 @@ +import type { + ProviderApprovalDecision, + ProviderRuntimeEvent, + ProviderSendTurnInput, + ProviderSession, + ProviderSessionStartInput, +} from "@t3tools/contracts"; +import type { ApprovalRequestId, CanonicalRequestType, ThreadId, TurnId } from "@t3tools/contracts"; + +export const PROVIDER = "kilo" as const; +export const DEFAULT_HOSTNAME = "127.0.0.1"; +// Kilo defaults to port 0 (OS-assigned), unlike OpenCode's 6733. +// We use 0 to always spawn a fresh server and parse the URL from stdout. +export const DEFAULT_PORT = 0; +export const SERVER_START_TIMEOUT_MS = 5000; +export const SERVER_PROBE_TIMEOUT_MS = 1500; + +// --------------------------------------------------------------------------- +// Provider / Session option types +// --------------------------------------------------------------------------- + +export type KiloProviderOptions = { + readonly serverUrl?: string; + readonly binaryPath?: string; + readonly hostname?: string; + readonly port?: number; + readonly workspace?: string; + readonly username?: string; + readonly password?: string; +}; + +export type KiloSessionStartInput = ProviderSessionStartInput & { + readonly providerOptions?: ProviderSessionStartInput["providerOptions"] & { + readonly kilo?: KiloProviderOptions; + }; +}; + +export type KiloSendTurnInput = ProviderSendTurnInput & { + readonly modelOptions?: ProviderSendTurnInput["modelOptions"] & { + readonly kilo?: { + readonly providerId?: string; + readonly modelId?: string; + readonly variant?: string; + readonly reasoningEffort?: string; + readonly agent?: string; + }; + }; +}; + +// --------------------------------------------------------------------------- +// Runtime event types +// --------------------------------------------------------------------------- + +export type KiloRuntimeRawSource = + | "kilo.server.event" + | "kilo.server.permission" + | "kilo.server.question"; + +export type KiloProviderRuntimeEvent = Omit & { + readonly provider: ProviderRuntimeEvent["provider"] | "kilo"; + readonly raw?: { + readonly source: KiloRuntimeRawSource; + readonly method?: string; + readonly messageType?: string; + readonly payload: unknown; + }; +}; + +export type KiloProviderSession = Omit & { + readonly provider: ProviderSession["provider"] | "kilo"; +}; + +// --------------------------------------------------------------------------- +// Model discovery types +// --------------------------------------------------------------------------- + +export type KiloModel = { + readonly id: string; + readonly name: string; + readonly variants?: Readonly>; +}; + +export type KiloListedProvider = { + readonly id: string; + readonly name?: string; + readonly models: Readonly>; +}; + +export type ProviderListResponse = { + readonly all: ReadonlyArray; + readonly connected: ReadonlyArray; +}; + +export type KiloConfiguredProvider = { + readonly id: string; + readonly name?: string; + readonly models: Readonly>; +}; + +export type ConfigProvidersResponse = { + readonly providers: ReadonlyArray; +}; + +export type KiloDiscoveredModel = { + slug: string; + name: string; + variants?: ReadonlyArray; + connected?: boolean; +}; + +export type KiloModelDiscoveryOptions = KiloProviderOptions & { + directory?: string; +}; + +// --------------------------------------------------------------------------- +// Event payload types +// --------------------------------------------------------------------------- + +export type QuestionInfo = { + readonly header: string; + readonly question: string; + readonly options: ReadonlyArray<{ + readonly label: string; + readonly description: string; + }>; + readonly multiple?: boolean; + readonly custom?: boolean; +}; + +export type KiloTodo = { + readonly content: string; + readonly status: "completed" | "in_progress" | string; + readonly priority?: string; +}; + +export type KiloToolState = + | { + readonly status: "pending"; + } + | { + readonly status: "running"; + readonly title: string; + readonly metadata?: Record; + } + | { + readonly status: "completed"; + readonly title: string; + readonly output?: string; + readonly metadata?: Record; + } + | { + readonly status: "error"; + readonly error: string; + readonly metadata?: Record; + }; + +export type KiloToolPart = { + readonly id: string; + readonly sessionID: string; + readonly messageID?: string; + readonly type: "tool"; + readonly tool?: string; + readonly state: KiloToolState; +}; + +export type KiloMessagePart = + | { + readonly id: string; + readonly sessionID: string; + readonly messageID?: string; + readonly type: "text"; + } + | { + readonly id: string; + readonly sessionID: string; + readonly messageID?: string; + readonly type: "reasoning"; + } + | KiloToolPart; + +// --------------------------------------------------------------------------- +// SSE event types +// --------------------------------------------------------------------------- + +export type EventSessionStatus = { + readonly type: "session.status"; + readonly properties: { + readonly sessionID: string; + readonly status: { + readonly type: "busy" | "retry" | "idle" | string; + }; + }; +}; + +/** + * Matches the SDK's `EventSessionError` type. The `error` union covers all + * known error names from `@opencode-ai/sdk/v2`: + * + * - ProviderAuthError: { providerID, message } + * - UnknownError: { message } + * - MessageAbortedError: { message } + * - StructuredOutputError: { message, retries } + * - ContextOverflowError: { message, responseBody? } + * - APIError: { message, statusCode?, isRetryable, ... } + * - MessageOutputLengthError: { [key: string]: unknown } + */ +export type EventSessionError = { + readonly type: "session.error"; + readonly properties: { + readonly sessionID?: string; + readonly error?: + | { + readonly name: "ProviderAuthError"; + readonly data: { + readonly providerID: string; + readonly message: string; + }; + } + | { + readonly name: "APIError"; + readonly data: { + readonly message: string; + readonly statusCode?: number; + readonly isRetryable: boolean; + readonly responseHeaders?: Record; + readonly responseBody?: string; + readonly metadata?: Record; + }; + } + | { + readonly name: "ContextOverflowError"; + readonly data: { + readonly message: string; + readonly responseBody?: string; + }; + } + | { + readonly name: "StructuredOutputError"; + readonly data: { + readonly message: string; + readonly retries: number; + }; + } + | { + readonly name: "UnknownError" | "MessageAbortedError"; + readonly data: { + readonly message: string; + }; + } + | { + readonly name: "MessageOutputLengthError"; + readonly data?: Record; + } + | { + readonly name: string; + readonly data?: { + readonly message?: string; + }; + }; + }; +}; + +export type EventPermissionAsked = { + readonly type: "permission.asked"; + readonly properties: { + readonly id: string; + readonly sessionID: string; + readonly permission?: string; + readonly title?: string; + readonly pattern?: string; + readonly metadata?: Record; + readonly tool?: string; + }; +}; + +export type EventPermissionReplied = { + readonly type: "permission.replied"; + readonly properties: { + readonly requestID: string; + readonly sessionID: string; + readonly reply: string; + }; +}; + +export type EventQuestionAsked = { + readonly type: "question.asked"; + readonly properties: { + readonly id: string; + readonly sessionID: string; + readonly questions: ReadonlyArray; + }; +}; + +export type EventQuestionReplied = { + readonly type: "question.replied"; + readonly properties: { + readonly requestID: string; + readonly sessionID: string; + readonly answers: ReadonlyArray>; + }; +}; + +export type EventQuestionRejected = { + readonly type: "question.rejected"; + readonly properties: { + readonly requestID: string; + readonly sessionID: string; + }; +}; + +export type EventMessagePartUpdated = { + readonly type: "message.part.updated"; + readonly properties: { + readonly part: KiloMessagePart; + }; +}; + +export type EventMessagePartDelta = { + readonly type: "message.part.delta"; + readonly properties: { + readonly sessionID: string; + readonly partID: string; + readonly delta: string; + }; +}; + +export type EventTodoUpdated = { + readonly type: "todo.updated"; + readonly properties: { + readonly sessionID: string; + readonly todos: ReadonlyArray; + }; +}; + +export type EventSessionIdle = { + readonly type: "session.idle"; + readonly properties: { + readonly sessionID: string; + }; +}; + +export type KiloFileDiff = { + readonly file: string; + readonly before: string; + readonly after: string; + readonly additions: number; + readonly deletions: number; +}; + +export type EventSessionDiff = { + readonly type: "session.diff"; + readonly properties: { + readonly sessionID: string; + readonly diff: ReadonlyArray; + }; +}; + +export type EventSessionCompacted = { + readonly type: "session.compacted"; + readonly properties: { + readonly sessionID: string; + }; +}; + +export type EventSessionUpdated = { + readonly type: "session.updated"; + readonly properties: { + readonly sessionID: string; + readonly info?: { + readonly title?: string; + readonly shareURL?: string; + readonly [key: string]: unknown; + }; + }; +}; + +export type EventVcsBranchUpdated = { + readonly type: "vcs.branch.updated"; + readonly properties: { + readonly sessionID?: string; + readonly branch: string; + }; +}; + +export type EventFileEdited = { + readonly type: "file.edited"; + readonly properties: { + readonly sessionID?: string; + readonly filename: string; + readonly fileId?: string; + }; +}; + +export type EventCommandExecuted = { + readonly type: "command.executed"; + readonly properties: { + readonly sessionID: string; + readonly command: string; + readonly args?: Record; + }; +}; + +export type EventMessagePartRemoved = { + readonly type: "message.part.removed"; + readonly properties: { + readonly sessionID: string; + readonly partID: string; + }; +}; + +export type KiloEvent = + | EventSessionStatus + | EventSessionError + | EventSessionIdle + | EventSessionDiff + | EventSessionCompacted + | EventSessionUpdated + | EventPermissionAsked + | EventPermissionReplied + | EventQuestionAsked + | EventQuestionReplied + | EventQuestionRejected + | EventMessagePartUpdated + | EventMessagePartDelta + | EventMessagePartRemoved + | EventTodoUpdated + | EventVcsBranchUpdated + | EventFileEdited + | EventCommandExecuted; + +// --------------------------------------------------------------------------- +// SDK client types +// --------------------------------------------------------------------------- + +export type KiloDataResponse = + | T + | { + readonly data: T; + readonly error?: undefined; + } + | { + readonly data?: undefined; + readonly error: unknown; + }; + +export type OpencodeClientConfig = { + readonly baseUrl: string; + readonly directory?: string; + readonly responseStyle?: "data" | string; + readonly throwOnError?: boolean; + readonly headers?: Record; +}; + +export type KiloClient = { + readonly session: { + readonly get: (input: { + readonly sessionID: string; + readonly workspace?: string; + }) => Promise; + readonly create: (input: { + readonly workspace?: string; + readonly title: string; + }) => Promise; + readonly promptAsync: (input: { + readonly sessionID: string; + readonly workspace?: string; + readonly model?: { + readonly providerID: string; + readonly modelID: string; + }; + readonly agent?: string; + readonly variant?: string; + readonly parts: ReadonlyArray<{ + readonly type: "text"; + readonly text: string; + }>; + }) => Promise; + readonly abort: (input: { + readonly sessionID: string; + readonly workspace?: string; + }) => Promise; + readonly messages: (input: { + readonly sessionID: string; + readonly workspace?: string; + }) => Promise>; + readonly revert: (input: { + readonly sessionID: string; + readonly messageID: string; + readonly workspace?: string; + }) => Promise; + readonly unrevert: (input: { + readonly sessionID: string; + readonly workspace?: string; + }) => Promise; + }; + readonly permission: { + readonly reply: (input: { + readonly requestID: string; + readonly workspace?: string; + readonly reply: "once" | "always" | "reject"; + }) => Promise; + }; + readonly question: { + readonly reply: (input: { + readonly requestID: string; + readonly workspace?: string; + readonly answers: ReadonlyArray>; + }) => Promise; + }; + readonly provider: { + readonly list: (input: { + readonly workspace?: string; + }) => Promise>; + }; + readonly config: { + readonly providers: (input: { + readonly workspace?: string; + }) => Promise>; + }; + readonly event: { + readonly subscribe: ( + input: { + readonly workspace?: string; + }, + options: { + readonly signal?: AbortSignal; + }, + ) => Promise<{ + readonly stream: AsyncIterable; + }>; + }; +}; + +export type KiloSdkModule = { + createOpencodeClient(options: OpencodeClientOptions): KiloClient; +}; + +export type OpencodeClientOptions = OpencodeClientConfig & { + directory?: string; +}; + +// --------------------------------------------------------------------------- +// Session context types +// --------------------------------------------------------------------------- + +export interface PendingPermissionRequest { + readonly requestId: ApprovalRequestId; + readonly requestType: CanonicalRequestType; +} + +export interface PendingQuestionRequest { + readonly requestId: ApprovalRequestId; + readonly questionIds: ReadonlyArray; + readonly questions: ReadonlyArray<{ + readonly answerIndex: number; + readonly id: string; + readonly header: string; + readonly question: string; + readonly options: ReadonlyArray<{ + readonly label: string; + readonly description: string; + }>; + }>; +} + +export interface PartStreamState { + readonly kind: "text" | "reasoning" | "tool"; + readonly streamKind?: "assistant_text" | "reasoning_text"; +} + +export interface KiloSessionContext { + readonly threadId: ThreadId; + readonly directory: string; + readonly workspace?: string; + readonly client: KiloClient; + readonly providerSessionId: string; + readonly pendingPermissions: Map; + readonly pendingQuestions: Map; + readonly partStreamById: Map; + readonly messageIds: string[]; + readonly streamAbortController: AbortController; + streamTask: Promise; + session: KiloProviderSession; + activeTurnId: TurnId | undefined; + lastError: string | undefined; +} + +export interface SharedServerState { + readonly baseUrl: string; + readonly authHeader?: string; + readonly child?: { + kill: () => boolean; + }; +} + +export interface KiloManagerEvents { + event: [ProviderRuntimeEvent]; +} diff --git a/apps/server/src/kilo/utils.ts b/apps/server/src/kilo/utils.ts new file mode 100644 index 0000000000..feb93babc2 --- /dev/null +++ b/apps/server/src/kilo/utils.ts @@ -0,0 +1,388 @@ +import { randomUUID } from "node:crypto"; + +import { + EventId, + TurnId, + type CanonicalRequestType, + type ProviderApprovalDecision, +} from "@t3tools/contracts"; + +import type { + ConfigProvidersResponse, + KiloConfiguredProvider, + KiloDiscoveredModel, + KiloFileDiff, + KiloListedProvider, + KiloModel, + KiloProviderSession, + KiloTodo, + KiloToolState, + ProviderListResponse, +} from "./types.ts"; + +// --------------------------------------------------------------------------- +// Generic helpers +// --------------------------------------------------------------------------- + +export function asRecord(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + return value as Record; +} + +export function asString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +export function eventId(prefix: string): EventId { + return EventId.makeUnsafe(`${prefix}:${randomUUID()}`); +} + +export function nowIso(): string { + return new Date().toISOString(); +} + +export function createTurnId(): TurnId { + return TurnId.makeUnsafe(`turn:${randomUUID()}`); +} + +export function textPart(text: string) { + return { + type: "text" as const, + text, + }; +} + +// --------------------------------------------------------------------------- +// Auth +// --------------------------------------------------------------------------- + +export function buildAuthHeader(username?: string, password?: string): string | undefined { + if (!password) { + return undefined; + } + const resolvedUsername = username && username.length > 0 ? username : "kilo"; + return `Basic ${Buffer.from(`${resolvedUsername}:${password}`).toString("base64")}`; +} + +// --------------------------------------------------------------------------- +// Session / resume helpers +// --------------------------------------------------------------------------- + +export function readResumeSessionId(resumeCursor: unknown): string | undefined { + const record = asRecord(resumeCursor); + return asString(record?.sessionId); +} + +export function stripTransientSessionFields(session: KiloProviderSession) { + const { activeTurnId: _activeTurnId, lastError: _lastError, ...rest } = session; + return rest; +} + +// --------------------------------------------------------------------------- +// Model parsing +// --------------------------------------------------------------------------- + +export function parseKiloModel(model: string | undefined): + | { + providerId: string; + modelId: string; + variant?: string; + } + | undefined { + const value = asString(model); + if (!value) { + return undefined; + } + const index = value.indexOf("/"); + if (index < 1 || index >= value.length - 1) { + return undefined; + } + const providerId = value.slice(0, index); + const modelAndVariant = value.slice(index + 1); + const variantIndex = modelAndVariant.lastIndexOf("#"); + const modelId = variantIndex >= 1 ? modelAndVariant.slice(0, variantIndex) : modelAndVariant; + const variant = + variantIndex >= 1 && variantIndex < modelAndVariant.length - 1 + ? modelAndVariant.slice(variantIndex + 1) + : undefined; + return { + providerId, + modelId, + ...(variant ? { variant } : {}), + }; +} + +const PREFERRED_VARIANT_ORDER = [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +] as const; + +function compareKiloVariantNames(left: string, right: string): number { + const leftIndex = PREFERRED_VARIANT_ORDER.indexOf( + left as (typeof PREFERRED_VARIANT_ORDER)[number], + ); + const rightIndex = PREFERRED_VARIANT_ORDER.indexOf( + right as (typeof PREFERRED_VARIANT_ORDER)[number], + ); + if (leftIndex >= 0 || rightIndex >= 0) { + if (leftIndex < 0) return 1; + if (rightIndex < 0) return -1; + if (leftIndex !== rightIndex) return leftIndex - rightIndex; + } + return left.localeCompare(right); +} + +function modelOptionsFromProvider( + providerId: string, + providerName: string, + model: KiloModel, + connected?: boolean, +): ReadonlyArray { + const variantNames = Object.keys(model.variants ?? {}) + .filter((variant) => variant.length > 0) + .toSorted(compareKiloVariantNames); + return [ + { + slug: `${providerId}/${model.id}`, + name: `${providerName} / ${model.name}`, + ...(variantNames.length > 0 ? { variants: variantNames } : {}), + ...(connected != null ? { connected } : {}), + }, + ]; +} + +export function parseProviderModels( + providers: ReadonlyArray< + Pick | KiloConfiguredProvider + >, + connectedIds?: ReadonlySet, +): ReadonlyArray { + const sorted = [...providers].sort((a, b) => { + const nameA = a.name || a.id; + const nameB = b.name || b.id; + return nameA.localeCompare(nameB); + }); + return sorted.flatMap((provider) => { + const providerName = provider.name || provider.id; + const isConnected = connectedIds ? connectedIds.has(provider.id) : undefined; + return Object.values(provider.models).flatMap((model) => + modelOptionsFromProvider(provider.id, providerName, model, isConnected), + ); + }); +} + +// --------------------------------------------------------------------------- +// Permission / request type mapping +// --------------------------------------------------------------------------- + +export function toKiloRequestType(permission: string | undefined): CanonicalRequestType { + switch (permission) { + case "bash": + return "exec_command_approval"; + case "edit": + case "write": + return "file_change_approval"; + case "read": + case "glob": + case "grep": + case "list": + case "codesearch": + case "lsp": + case "external_directory": + return "file_read_approval"; + default: + return "unknown"; + } +} + +export function toPermissionReply( + decision: ProviderApprovalDecision, +): "once" | "always" | "reject" { + switch (decision) { + case "acceptForSession": + return "always"; + case "accept": + return "once"; + case "decline": + case "cancel": + return "reject"; + } +} + +// --------------------------------------------------------------------------- +// Tool state helpers +// --------------------------------------------------------------------------- + +function readMetadataString( + metadata: Record | undefined, + key: string, +): string | undefined { + const value = metadata?.[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +export function toolStateTitle(state: KiloToolState): string | undefined { + switch (state.status) { + case "pending": + return undefined; + case "running": + case "completed": + return state.title; + case "error": + return readMetadataString(state.metadata, "title"); + } +} + +export function toolStateDetail(state: KiloToolState): string | undefined { + switch (state.status) { + case "pending": + return undefined; + case "running": + return readMetadataString(state.metadata, "summary") ?? state.title; + case "completed": + return readMetadataString(state.metadata, "summary") ?? state.output; + case "error": + return state.error; + } +} + +export function toPlanStepStatus(status: string): "pending" | "inProgress" | "completed" { + switch (status) { + case "completed": + return "completed"; + case "in_progress": + return "inProgress"; + default: + return "pending"; + } +} + +export function toToolItemType( + toolName: string | undefined, +): + | "command_execution" + | "file_change" + | "web_search" + | "collab_agent_tool_call" + | "dynamic_tool_call" { + switch (toolName) { + case "bash": + return "command_execution"; + case "write": + case "edit": + case "apply_patch": + return "file_change"; + case "webfetch": + return "web_search"; + case "task": + return "collab_agent_tool_call"; + default: + return "dynamic_tool_call"; + } +} + +export function toToolTitle(toolName: string | undefined): string { + const value = asString(toolName) ?? "tool"; + return value.slice(0, 1).toUpperCase() + value.slice(1); +} + +export function toToolLifecycleEventType( + previous: { kind: string } | undefined, + status: KiloToolState["status"], +): "item.started" | "item.updated" | "item.completed" { + if (status === "completed" || status === "error") { + return "item.completed"; + } + return previous?.kind === "tool" ? "item.updated" : "item.started"; +} + +// --------------------------------------------------------------------------- +// Server URL parsing +// --------------------------------------------------------------------------- + +export function parseServerUrl(output: string): string | undefined { + const match = output.match(/kilo server listening on\s+(https?:\/\/[^\s]+)(?=\r?\n)/); + return match?.[1]; +} + +// --------------------------------------------------------------------------- +// SDK response helpers +// --------------------------------------------------------------------------- + +export async function readJsonData(promise: Promise): Promise { + return promise; +} + +export function readProviderListResponse( + value: + | ProviderListResponse + | { data: ProviderListResponse; error?: undefined } + | { data?: undefined; error: unknown }, +): ProviderListResponse { + if ("all" in value && "connected" in value) { + return value; + } + if (value.data !== undefined) { + return value.data; + } + throw new Error("Kilo SDK returned an empty provider list response"); +} + +export function readConfigProvidersResponse( + value: + | ConfigProvidersResponse + | { data: ConfigProvidersResponse; error?: undefined } + | { data?: undefined; error: unknown }, +): ConfigProvidersResponse { + if ("providers" in value) { + return value; + } + if (value.data !== undefined) { + return value.data; + } + throw new Error("Kilo SDK returned an empty config providers response"); +} + +// --------------------------------------------------------------------------- +// Diff helpers +// --------------------------------------------------------------------------- + +/** + * Converts an array of Kilo file diffs into a single unified diff string. + * The format approximates standard unified diff output (--- a/file, +++ b/file, + * with addition/deletion counts) without full line-level hunks since Kilo + * only provides before/after snapshots and summary counts. + */ +export function fileDiffsToUnifiedDiff(diffs: ReadonlyArray): string { + if (diffs.length === 0) { + return ""; + } + return diffs + .map((d) => { + const header = `--- a/${d.file}\n+++ b/${d.file}`; + const stats = `@@ +${d.additions},-${d.deletions} @@`; + return `${header}\n${stats}`; + }) + .join("\n"); +} + +// --------------------------------------------------------------------------- +// Todo / plan helpers +// --------------------------------------------------------------------------- + +/** + * Prefixes a todo's content with its priority when available, e.g. `"[HIGH] task"`. + */ +export function todoPriorityPrefix(todo: KiloTodo): string { + if (todo.priority && todo.priority.length > 0) { + return `[${todo.priority.toUpperCase()}] ${todo.content}`; + } + return todo.content; +} diff --git a/apps/server/src/kiloServerManager.ts b/apps/server/src/kiloServerManager.ts index a3d04a862f..9c8f1fd0d5 100644 --- a/apps/server/src/kiloServerManager.ts +++ b/apps/server/src/kiloServerManager.ts @@ -1,15 +1,10 @@ import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; -import { spawn } from "node:child_process"; import { ApprovalRequestId, - EventId, - RuntimeItemId, - RuntimeRequestId, ThreadId, TurnId, - type CanonicalRequestType, type ProviderApprovalDecision, type ProviderRuntimeEvent, type ProviderSendTurnInput, @@ -20,736 +15,39 @@ import { } from "@t3tools/contracts"; import type { ProviderThreadSnapshot } from "./provider/Services/ProviderAdapter.ts"; -const PROVIDER = "kilo" as const; -const DEFAULT_HOSTNAME = "127.0.0.1"; -// Kilo defaults to port 0 (OS-assigned), unlike OpenCode's 6733. -// We use 0 to always spawn a fresh server and parse the URL from stdout. -const DEFAULT_PORT = 0; -const SERVER_START_TIMEOUT_MS = 5000; -const SERVER_PROBE_TIMEOUT_MS = 1500; - -type KiloProviderOptions = { - readonly serverUrl?: string; - readonly binaryPath?: string; - readonly hostname?: string; - readonly port?: number; - readonly workspace?: string; - readonly username?: string; - readonly password?: string; -}; - -type KiloSessionStartInput = ProviderSessionStartInput & { - readonly providerOptions?: ProviderSessionStartInput["providerOptions"] & { - readonly kilo?: KiloProviderOptions; - }; -}; - -type KiloSendTurnInput = ProviderSendTurnInput & { - readonly modelOptions?: ProviderSendTurnInput["modelOptions"] & { - readonly kilo?: { - readonly providerId?: string; - readonly modelId?: string; - readonly variant?: string; - readonly reasoningEffort?: string; - readonly agent?: string; - }; - }; -}; - -type KiloProviderSession = Omit & { - readonly provider: ProviderSession["provider"] | "kilo"; -}; - -type KiloModel = { - readonly id: string; - readonly name: string; - readonly variants?: Readonly>; -}; - -type KiloListedProvider = { - readonly id: string; - readonly name?: string; - readonly models: Readonly>; -}; - -type ProviderListResponse = { - readonly all: ReadonlyArray; - readonly connected: ReadonlyArray; -}; - -type KiloConfiguredProvider = { - readonly id: string; - readonly name?: string; - readonly models: Readonly>; -}; - -type ConfigProvidersResponse = { - readonly providers: ReadonlyArray; -}; - -type QuestionInfo = { - readonly header: string; - readonly question: string; - readonly options: ReadonlyArray<{ - readonly label: string; - readonly description: string; - }>; -}; - -type KiloTodo = { - readonly content: string; - readonly status: "completed" | "in_progress" | string; -}; - -type KiloToolState = - | { - readonly status: "pending"; - } - | { - readonly status: "running"; - readonly title: string; - readonly metadata?: Record; - } - | { - readonly status: "completed"; - readonly title: string; - readonly output?: string; - readonly metadata?: Record; - } - | { - readonly status: "error"; - readonly error: string; - readonly metadata?: Record; - }; - -type KiloToolPart = { - readonly id: string; - readonly sessionID: string; - readonly type: "tool"; - readonly tool?: string; - readonly state: KiloToolState; -}; - -type KiloMessagePart = - | { - readonly id: string; - readonly sessionID: string; - readonly type: "text"; - } - | { - readonly id: string; - readonly sessionID: string; - readonly type: "reasoning"; - } - | KiloToolPart; - -type EventSessionStatus = { - readonly type: "session.status"; - readonly properties: { - readonly sessionID: string; - readonly status: { - readonly type: "busy" | "retry" | "idle" | string; - }; - }; -}; - -type EventSessionError = { - readonly type: "session.error"; - readonly properties: { - readonly sessionID?: string; - readonly error?: - | { - readonly name: - | "ProviderAuthError" - | "UnknownError" - | "MessageAbortedError" - | "StructuredOutputError" - | "ContextOverflowError" - | "APIError"; - readonly data: { - readonly message: string; - }; - } - | { - readonly name: "MessageOutputLengthError"; - readonly data?: Record; - } - | { - readonly name: string; - readonly data?: { - readonly message?: string; - }; - }; - }; -}; - -type EventPermissionAsked = { - readonly type: "permission.asked"; - readonly properties: { - readonly id: string; - readonly sessionID: string; - readonly permission?: string; - }; -}; - -type EventPermissionReplied = { - readonly type: "permission.replied"; - readonly properties: { - readonly requestID: string; - readonly sessionID: string; - readonly reply: string; - }; -}; - -type EventQuestionAsked = { - readonly type: "question.asked"; - readonly properties: { - readonly id: string; - readonly sessionID: string; - readonly questions: ReadonlyArray; - }; -}; - -type EventQuestionReplied = { - readonly type: "question.replied"; - readonly properties: { - readonly requestID: string; - readonly sessionID: string; - readonly answers: ReadonlyArray>; - }; -}; - -type EventQuestionRejected = { - readonly type: "question.rejected"; - readonly properties: { - readonly requestID: string; - readonly sessionID: string; - }; -}; - -type EventMessagePartUpdated = { - readonly type: "message.part.updated"; - readonly properties: { - readonly part: KiloMessagePart; - }; -}; - -type EventMessagePartDelta = { - readonly type: "message.part.delta"; - readonly properties: { - readonly sessionID: string; - readonly partID: string; - readonly delta: string; - }; -}; - -type EventTodoUpdated = { - readonly type: "todo.updated"; - readonly properties: { - readonly sessionID: string; - readonly todos: ReadonlyArray; - }; -}; - -type KiloEvent = - | EventSessionStatus - | EventSessionError - | EventPermissionAsked - | EventPermissionReplied - | EventQuestionAsked - | EventQuestionReplied - | EventQuestionRejected - | EventMessagePartUpdated - | EventMessagePartDelta - | EventTodoUpdated; - -type KiloDataResponse = - | T - | { - readonly data: T; - readonly error?: undefined; - } - | { - readonly data?: undefined; - readonly error: unknown; - }; - -type OpencodeClientConfig = { - readonly baseUrl: string; - readonly directory?: string; - readonly responseStyle?: "data" | string; - readonly throwOnError?: boolean; - readonly headers?: Record; -}; - -type OpencodeClient = { - readonly session: { - readonly get: (input: { - readonly sessionID: string; - readonly workspace?: string; - }) => Promise; - readonly create: (input: { - readonly workspace?: string; - readonly title: string; - }) => Promise; - readonly promptAsync: (input: { - readonly sessionID: string; - readonly workspace?: string; - readonly model?: { - readonly providerID: string; - readonly modelID: string; - }; - readonly agent?: string; - readonly variant?: string; - readonly parts: ReadonlyArray<{ - readonly type: "text"; - readonly text: string; - }>; - }) => Promise; - readonly abort: (input: { - readonly sessionID: string; - readonly workspace?: string; - }) => Promise; - readonly messages: (input: { - readonly sessionID: string; - readonly workspace?: string; - }) => Promise>; - }; - readonly permission: { - readonly reply: (input: { - readonly requestID: string; - readonly workspace?: string; - readonly reply: "once" | "always" | "reject"; - }) => Promise; - }; - readonly question: { - readonly reply: (input: { - readonly requestID: string; - readonly workspace?: string; - readonly answers: ReadonlyArray>; - }) => Promise; - }; - readonly provider: { - readonly list: (input: { - readonly workspace?: string; - }) => Promise>; - }; - readonly config: { - readonly providers: (input: { - readonly workspace?: string; - }) => Promise>; - }; - readonly event: { - readonly subscribe: ( - input: { - readonly workspace?: string; - }, - options: { - readonly signal?: AbortSignal; - }, - ) => Promise<{ - readonly stream: AsyncIterable; - }>; - }; -}; - -type KiloSdkModule = { - createOpencodeClient(options: OpencodeClientOptions): OpencodeClient; -}; - -type OpencodeClientOptions = OpencodeClientConfig & { - directory?: string; -}; -type KiloModelDiscoveryOptions = KiloProviderOptions & { - directory?: string; -}; -type KiloDiscoveredModel = { - slug: string; - name: string; - variants?: ReadonlyArray; - connected?: boolean; -}; - -interface KiloManagerEvents { - event: [ProviderRuntimeEvent]; -} - -interface PendingPermissionRequest { - readonly requestId: ApprovalRequestId; - readonly requestType: CanonicalRequestType; -} - -interface PendingQuestionRequest { - readonly requestId: ApprovalRequestId; - readonly questionIds: ReadonlyArray; - readonly questions: ReadonlyArray<{ - readonly answerIndex: number; - readonly id: string; - readonly header: string; - readonly question: string; - readonly options: ReadonlyArray<{ - readonly label: string; - readonly description: string; - }>; - }>; -} - -interface PartStreamState { - readonly kind: "text" | "reasoning" | "tool"; - readonly streamKind?: "assistant_text" | "reasoning_text"; -} - -interface KiloSessionContext { - readonly threadId: ThreadId; - readonly directory: string; - readonly workspace?: string; - readonly client: OpencodeClient; - readonly providerSessionId: string; - readonly pendingPermissions: Map; - readonly pendingQuestions: Map; - readonly partStreamById: Map; - readonly streamAbortController: AbortController; - streamTask: Promise; - session: KiloProviderSession; - activeTurnId: TurnId | undefined; - lastError: string | undefined; -} - -interface SharedServerState { - readonly baseUrl: string; - readonly authHeader?: string; - readonly child?: { - kill: () => boolean; - }; -} - -function asRecord(value: unknown): Record | undefined { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return undefined; - } - return value as Record; -} - -function asString(value: unknown): string | undefined { - return typeof value === "string" && value.length > 0 ? value : undefined; -} - -function eventId(prefix: string): EventId { - return EventId.makeUnsafe(`${prefix}:${randomUUID()}`); -} - -function nowIso(): string { - return new Date().toISOString(); -} - -function buildAuthHeader(username?: string, password?: string): string | undefined { - if (!password) { - return undefined; - } - const resolvedUsername = username && username.length > 0 ? username : "kilo"; - return `Basic ${Buffer.from(`${resolvedUsername}:${password}`).toString("base64")}`; -} - -function parseServerUrl(output: string): string | undefined { - const match = output.match(/kilo server listening on\s+(https?:\/\/[^\s]+)(?=\r?\n)/); - return match?.[1]; -} - -async function probeServer(baseUrl: string, authHeader?: string): Promise { - const response = await fetch(`${baseUrl}/global/health`, { - method: "GET", - ...(authHeader ? { headers: { Authorization: authHeader } } : {}), - signal: AbortSignal.timeout(SERVER_PROBE_TIMEOUT_MS), - }).catch(() => undefined); - return response?.ok === true; -} - -function readResumeState( - resumeCursor: unknown, -): { sessionId: string; workspace?: string } | undefined { - const record = asRecord(resumeCursor); - const sessionId = asString(record?.sessionId); - if (!sessionId) return undefined; - const workspace = asString(record?.workspace); - return { sessionId, ...(workspace ? { workspace } : {}) }; -} - -function parseOpencodeModel(model: string | undefined): - | { - providerId: string; - modelId: string; - variant?: string; - } - | undefined { - const value = asString(model); - if (!value) { - return undefined; - } - const index = value.indexOf("/"); - if (index < 1 || index >= value.length - 1) { - return undefined; - } - const providerId = value.slice(0, index); - const modelAndVariant = value.slice(index + 1); - const variantIndex = modelAndVariant.lastIndexOf("#"); - const modelId = variantIndex >= 1 ? modelAndVariant.slice(0, variantIndex) : modelAndVariant; - const variant = - variantIndex >= 1 && variantIndex < modelAndVariant.length - 1 - ? modelAndVariant.slice(variantIndex + 1) - : undefined; - return { - providerId, - modelId, - ...(variant ? { variant } : {}), - }; -} - -const PREFERRED_VARIANT_ORDER = [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh", - "max", -] as const; - -function compareKiloVariantNames(left: string, right: string): number { - const leftIndex = PREFERRED_VARIANT_ORDER.indexOf( - left as (typeof PREFERRED_VARIANT_ORDER)[number], - ); - const rightIndex = PREFERRED_VARIANT_ORDER.indexOf( - right as (typeof PREFERRED_VARIANT_ORDER)[number], - ); - if (leftIndex >= 0 || rightIndex >= 0) { - if (leftIndex < 0) return 1; - if (rightIndex < 0) return -1; - if (leftIndex !== rightIndex) return leftIndex - rightIndex; - } - return left.localeCompare(right); -} - -function modelOptionsFromProvider( - providerId: string, - providerName: string, - model: KiloModel, - connected?: boolean, -): ReadonlyArray { - const variantNames = Object.keys(model.variants ?? {}) - .filter((variant) => variant.length > 0) - .toSorted(compareKiloVariantNames); - return [ - { - slug: `${providerId}/${model.id}`, - name: `${providerName} / ${model.name}`, - ...(variantNames.length > 0 ? { variants: variantNames } : {}), - ...(connected != null ? { connected } : {}), - }, - ]; -} - -function parseProviderModels( - providers: ReadonlyArray< - Pick | KiloConfiguredProvider - >, - connectedIds?: ReadonlySet, -): ReadonlyArray { - const sorted = [...providers].sort((a, b) => { - const nameA = a.name || a.id; - const nameB = b.name || b.id; - return nameA.localeCompare(nameB); - }); - return sorted.flatMap((provider) => { - const providerName = provider.name || provider.id; - const isConnected = connectedIds ? connectedIds.has(provider.id) : undefined; - return Object.values(provider.models).flatMap((model) => - modelOptionsFromProvider(provider.id, providerName, model, isConnected), - ); - }); -} - -function toOpencodeRequestType(permission: string | undefined): CanonicalRequestType { - switch (permission) { - case "bash": - return "exec_command_approval"; - case "edit": - case "write": - return "file_change_approval"; - case "read": - case "glob": - case "grep": - case "list": - case "codesearch": - case "lsp": - case "external_directory": - return "file_read_approval"; - default: - return "unknown"; - } -} - -function toPermissionReply(decision: ProviderApprovalDecision): "once" | "always" | "reject" { - switch (decision) { - case "acceptForSession": - return "always"; - case "accept": - return "once"; - case "decline": - case "cancel": - return "reject"; - } -} - -function createTurnId(): TurnId { - return TurnId.makeUnsafe(`turn:${randomUUID()}`); -} - -function textPart(text: string) { - return { - type: "text" as const, - text, - }; -} - -function readMetadataString( - metadata: Record | undefined, - key: string, -): string | undefined { - const value = metadata?.[key]; - return typeof value === "string" && value.length > 0 ? value : undefined; -} - -function sessionErrorMessage(error: EventSessionError["properties"]["error"]): string | undefined { - if (!error) { - return undefined; - } - - switch (error.name) { - case "ProviderAuthError": - case "UnknownError": - case "MessageAbortedError": - case "StructuredOutputError": - case "ContextOverflowError": - case "APIError": - return error.data?.message; - case "MessageOutputLengthError": - return "Kilo response exceeded output length"; - default: { - const msg = (error.data as { message?: string } | undefined)?.message; - return msg ?? `Kilo error: ${error.name}`; - } - } -} - -function toolStateTitle(state: KiloToolState): string | undefined { - switch (state.status) { - case "pending": - return undefined; - case "running": - case "completed": - return state.title; - case "error": - return readMetadataString(state.metadata, "title"); - } -} - -function toolStateDetail(state: KiloToolState): string | undefined { - switch (state.status) { - case "pending": - return undefined; - case "running": - return readMetadataString(state.metadata, "summary") ?? state.title; - case "completed": - return readMetadataString(state.metadata, "summary") ?? state.output; - case "error": - return state.error; - } -} - -function toPlanStepStatus(status: KiloTodo["status"]): "pending" | "inProgress" | "completed" { - switch (status) { - case "completed": - return "completed"; - case "in_progress": - return "inProgress"; - default: - return "pending"; - } -} - -function toToolItemType( - toolName: string | undefined, -): - | "command_execution" - | "file_change" - | "web_search" - | "collab_agent_tool_call" - | "dynamic_tool_call" { - switch (toolName) { - case "bash": - return "command_execution"; - case "write": - case "edit": - case "apply_patch": - return "file_change"; - case "webfetch": - return "web_search"; - case "task": - return "collab_agent_tool_call"; - default: - return "dynamic_tool_call"; - } -} - -function toToolTitle(toolName: string | undefined): string { - const value = asString(toolName) ?? "tool"; - return value.slice(0, 1).toUpperCase() + value.slice(1); -} - -function toToolLifecycleEventType( - previous: PartStreamState | undefined, - status: KiloToolState["status"], -): "item.started" | "item.updated" | "item.completed" { - if (status === "completed" || status === "error") { - return "item.completed"; - } - return previous?.kind === "tool" ? "item.updated" : "item.started"; -} - -function readProviderListResponse( - value: - | ProviderListResponse - | { data: ProviderListResponse; error?: undefined } - | { data?: undefined; error: unknown }, -): ProviderListResponse { - if ("all" in value && "connected" in value) { - return value; - } - if (value.data !== undefined) { - return value.data; - } - throw new Error("Kilo SDK returned an empty provider list response"); -} - -function readConfigProvidersResponse( - value: - | ConfigProvidersResponse - | { data: ConfigProvidersResponse; error?: undefined } - | { data?: undefined; error: unknown }, -): ConfigProvidersResponse { - if ("providers" in value) { - return value; - } - if (value.data !== undefined) { - return value.data; - } - throw new Error("Kilo SDK returned an empty config providers response"); -} - -function stripTransientSessionFields(session: KiloProviderSession) { - const { activeTurnId: _activeTurnId, lastError: _lastError, ...rest } = session; - return rest; -} +import { + PROVIDER, + type KiloManagerEvents, + type KiloModelDiscoveryOptions, + type KiloProviderOptions, + type KiloProviderRuntimeEvent, + type KiloProviderSession, + type KiloSessionContext, + type KiloSessionStartInput, + type KiloSendTurnInput, + type KiloDiscoveredModel, + type SharedServerState, +} from "./kilo/types.ts"; +import { + asRecord, + asString, + createTurnId, + eventId, + nowIso, + parseKiloModel, + parseProviderModels, + readConfigProvidersResponse, + readJsonData, + readProviderListResponse, + readResumeSessionId, + stripTransientSessionFields, + textPart, + toPermissionReply, +} from "./kilo/utils.ts"; +import { handleEvent } from "./kilo/eventHandlers.ts"; +import { createClient, ensureServer } from "./kilo/serverLifecycle.ts"; + +export { type KiloDiscoveredModel, type KiloModelDiscoveryOptions } from "./kilo/types.ts"; export class KiloServerManager extends EventEmitter { private readonly sessions = new Map(); @@ -773,10 +71,9 @@ export class KiloServerManager extends EventEmitter { const directory = kiloInput.cwd ?? process.cwd(); const options = kiloInput.providerOptions?.kilo; - const resumeState = readResumeState(kiloInput.resumeCursor); - const workspace = options?.workspace ?? resumeState?.workspace; + const workspace = options?.workspace; const sharedServer = await this.ensureServer(options); - const client = await this.createClient({ + const client = await createClient({ baseUrl: sharedServer.baseUrl, directory, responseStyle: "data", @@ -790,22 +87,24 @@ export class KiloServerManager extends EventEmitter { : {}), }); - const resumedSessionId = resumeState?.sessionId; + const resumedSessionId = readResumeSessionId(kiloInput.resumeCursor); const resumedSession = resumedSessionId - ? await client.session - .get({ + ? await readJsonData( + client.session.get({ sessionID: resumedSessionId, ...(workspace ? { workspace } : {}), - }) - .catch(() => undefined) + }), + ).catch(() => undefined) : undefined; const createdSession = resumedSession ?? - (await client.session.create({ - ...(workspace ? { workspace } : {}), - title: `T3 thread ${input.threadId}`, - })); + (await readJsonData( + client.session.create({ + ...(workspace ? { workspace } : {}), + title: `T3 thread ${input.threadId}`, + }), + )); const createdAt = nowIso(); const providerSessionId = asString(asRecord(createdSession)?.id); @@ -838,6 +137,7 @@ export class KiloServerManager extends EventEmitter { pendingPermissions: new Map(), pendingQuestions: new Map(), partStreamById: new Map(), + messageIds: [], streamAbortController, streamTask: Promise.resolve(), session: initialSession, @@ -882,25 +182,34 @@ export class KiloServerManager extends EventEmitter { }, }); + this.emitRuntimeEvent({ + type: "session.configured", + eventId: eventId("kilo-session-configured"), + provider: PROVIDER, + threadId: kiloInput.threadId, + createdAt, + payload: { + config: { + provider: PROVIDER, + sessionId: providerSessionId, + ...(kiloInput.model ? { model: kiloInput.model } : {}), + directory, + ...(workspace ? { workspace } : {}), + }, + }, + }); + return initialSession as ProviderSession; } async sendTurn(input: ProviderSendTurnInput): Promise { - if (input.attachments && input.attachments.length > 0) { - throw new Error("Attachments are not supported by Kilo"); - } const kiloInput = input as KiloSendTurnInput; const context = this.requireSession(input.threadId); - if (context.activeTurnId) { - throw new Error( - `Kilo thread '${input.threadId}' already has an active turn '${context.activeTurnId}'`, - ); - } const turnId = createTurnId(); const agent = kiloInput.modelOptions?.kilo?.agent ?? (kiloInput.interactionMode === "plan" ? "plan" : undefined); - const parsedModel = parseOpencodeModel(kiloInput.model); + const parsedModel = parseKiloModel(kiloInput.model); const providerId = kiloInput.modelOptions?.kilo?.providerId ?? parsedModel?.providerId; const modelId = kiloInput.modelOptions?.kilo?.modelId ?? parsedModel?.modelId ?? kiloInput.model; @@ -943,21 +252,23 @@ export class KiloServerManager extends EventEmitter { }); try { - await context.client.session.promptAsync({ - sessionID: context.providerSessionId, - ...(context.workspace ? { workspace: context.workspace } : {}), - ...(providerId && modelId - ? { - model: { - providerID: providerId, - modelID: modelId, - }, - } - : {}), - ...(agent ? { agent } : {}), - ...(variant ? { variant } : {}), - parts: [textPart(kiloInput.input ?? "")], - }); + await readJsonData( + context.client.session.promptAsync({ + sessionID: context.providerSessionId, + ...(context.workspace ? { workspace: context.workspace } : {}), + ...(providerId && modelId + ? { + model: { + providerID: providerId, + modelID: modelId, + }, + } + : {}), + ...(agent ? { agent } : {}), + ...(variant ? { variant } : {}), + parts: [textPart(kiloInput.input ?? "")], + }), + ); } catch (cause) { const message = cause instanceof Error ? cause.message : "Kilo failed to start turn"; context.activeTurnId = undefined; @@ -1016,10 +327,30 @@ export class KiloServerManager extends EventEmitter { async interruptTurn(threadId: ThreadId): Promise { const context = this.requireSession(threadId); - await context.client.session.abort({ - sessionID: context.providerSessionId, - ...(context.workspace ? { workspace: context.workspace } : {}), - }); + try { + await readJsonData( + context.client.session.abort({ + sessionID: context.providerSessionId, + ...(context.workspace ? { workspace: context.workspace } : {}), + }), + ); + } catch (cause) { + const message = cause instanceof Error ? cause.message : "Kilo session abort failed"; + this.emitRuntimeEvent({ + type: "runtime.error", + eventId: eventId("kilo-interrupt-error"), + provider: PROVIDER, + threadId, + createdAt: nowIso(), + ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), + payload: { + message, + class: "transport_error", + }, + }); + // Still clean up local state even if the abort RPC failed so the UI + // does not stay stuck in a "running" state. + } const interruptedTurnId = context.activeTurnId; if (interruptedTurnId) { this.emitRuntimeEvent({ @@ -1048,11 +379,13 @@ export class KiloServerManager extends EventEmitter { decision: ProviderApprovalDecision, ): Promise { const context = this.requireSession(threadId); - await context.client.permission.reply({ - requestID: requestId, - ...(context.workspace ? { workspace: context.workspace } : {}), - reply: toPermissionReply(decision), - }); + await readJsonData( + context.client.permission.reply({ + requestID: requestId, + ...(context.workspace ? { workspace: context.workspace } : {}), + reply: toPermissionReply(decision), + }), + ); } async respondToUserInput( @@ -1082,19 +415,23 @@ export class KiloServerManager extends EventEmitter { } } - await context.client.question.reply({ - requestID: requestId, - ...(context.workspace ? { workspace: context.workspace } : {}), - answers: orderedAnswers, - }); + await readJsonData( + context.client.question.reply({ + requestID: requestId, + ...(context.workspace ? { workspace: context.workspace } : {}), + answers: orderedAnswers, + }), + ); } async readThread(threadId: ThreadId): Promise { const context = this.requireSession(threadId); - const messages = await context.client.session.messages({ - sessionID: context.providerSessionId, - ...(context.workspace ? { workspace: context.workspace } : {}), - }); + const messages = await readJsonData( + context.client.session.messages({ + sessionID: context.providerSessionId, + ...(context.workspace ? { workspace: context.workspace } : {}), + }), + ); const turns = (Array.isArray(messages) ? messages : []).map((entry) => { const info = asRecord(asRecord(entry)?.info); @@ -1111,15 +448,33 @@ export class KiloServerManager extends EventEmitter { }; } - async rollbackThread(threadId: ThreadId): Promise { - throw new Error(`Kilo rollback is not implemented for thread '${threadId}'`); + async rollbackThread(threadId: ThreadId, numTurns = 1): Promise { + const context = this.requireSession(threadId); + const ids = context.messageIds; + if (ids.length === 0) { + throw new Error(`No tracked messages for Kilo thread '${threadId}' — cannot rollback`); + } + // Target the message just before the last `numTurns` messages. + // Each message ID in the tracked list corresponds to one assistant turn. + const targetIndex = Math.max(0, ids.length - numTurns - 1); + const targetMessageId = ids[targetIndex]!; + await readJsonData( + context.client.session.revert({ + sessionID: context.providerSessionId, + messageID: targetMessageId, + ...(context.workspace ? { workspace: context.workspace } : {}), + }), + ); + // Trim tracked IDs to match the reverted state + context.messageIds.length = targetIndex + 1; + return this.readThread(threadId); } async listModels( options?: KiloModelDiscoveryOptions, ): Promise> { const shared = await this.ensureServer(options); - const client = await this.createClient({ + const client = await createClient({ baseUrl: shared.baseUrl, ...(options?.directory ? { directory: options.directory } : {}), responseStyle: "data", @@ -1133,7 +488,9 @@ export class KiloServerManager extends EventEmitter { : {}), }); const payload = readProviderListResponse( - await client.provider.list(options?.workspace ? { workspace: options.workspace } : {}), + await readJsonData( + client.provider.list(options?.workspace ? { workspace: options.workspace } : {}), + ), ); // Show all configured providers, marking which ones are connected. // Fall back to config.providers if the provider.list response has @@ -1144,7 +501,9 @@ export class KiloServerManager extends EventEmitter { return listed; } const configured = readConfigProvidersResponse( - await client.config.providers(options?.workspace ? { workspace: options.workspace } : {}), + await readJsonData( + client.config.providers(options?.workspace ? { workspace: options.workspace } : {}), + ), ); return parseProviderModels(configured.providers); } @@ -1154,6 +513,18 @@ export class KiloServerManager extends EventEmitter { if (!context) { return; } + this.emitRuntimeEvent({ + type: "session.exited", + eventId: eventId("kilo-session-exited"), + provider: PROVIDER, + threadId, + createdAt: nowIso(), + payload: { + reason: "Session stopped", + exitKind: "graceful", + recoverable: true, + }, + }); context.streamAbortController.abort(); context.session = { ...stripTransientSessionFields(context.session), @@ -1189,110 +560,12 @@ export class KiloServerManager extends EventEmitter { } this.serverPromise = (async () => { - const authHeader = buildAuthHeader(options?.username, options?.password); - if (options?.serverUrl) { - const shared = { - baseUrl: options.serverUrl, - ...(authHeader ? { authHeader } : {}), - } satisfies SharedServerState; - this.server = shared; - return shared; - } - - const hostname = options?.hostname ?? DEFAULT_HOSTNAME; - const port = Math.trunc(options?.port ?? DEFAULT_PORT); - const baseUrl = `http://${hostname}:${port}`; - const healthy = await probeServer(baseUrl, authHeader); - if (healthy) { - const shared = { - baseUrl, - ...(authHeader ? { authHeader } : {}), - } satisfies SharedServerState; - this.server = shared; - return shared; - } - - const binaryPath = options?.binaryPath ?? "kilo"; - const child = spawn(binaryPath, ["serve", `--hostname=${hostname}`, `--port=${port}`], { - env: { - ...process.env, - ...(options?.username ? { KILO_SERVER_USERNAME: options.username } : {}), - ...(options?.password ? { KILO_SERVER_PASSWORD: options.password } : {}), - }, - stdio: ["ignore", "pipe", "pipe"], - }); - - const startedBaseUrl = await new Promise((resolve, reject) => { - let output = ""; - - const onChunk = (chunk: Buffer) => { - output += chunk.toString(); - const url = parseServerUrl(output); - if (!url) { - return; - } - cleanup(); - resolve(url); - }; - - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - - const onExit = (code: number | null) => { - cleanup(); - void probeServer(baseUrl, authHeader).then((reuse) => { - if (reuse) { - resolve(baseUrl); - return; - } - const detail = output.trim().replaceAll(/\s+/g, " ").slice(0, 400); - reject( - new Error( - `Kilo server exited before startup completed (code ${code})${ - detail.length > 0 ? `: ${detail}` : "" - }`, - ), - ); - }); - }; - - const cleanup = () => { - clearTimeout(timeout); - child.stdout.off("data", onChunk); - child.stderr.off("data", onChunk); - child.off("error", onError); - child.off("exit", onExit); - }; - - const timeout = setTimeout(() => { - cleanup(); - try { - child.kill(); - } catch { - // Process may already be dead. - } - reject( - new Error( - `Timed out waiting for Kilo server to start after ${SERVER_START_TIMEOUT_MS}ms`, - ), - ); - }, SERVER_START_TIMEOUT_MS); - - child.stdout.on("data", onChunk); - child.stderr.on("data", onChunk); - child.once("error", onError); - child.once("exit", onExit); + const result = await ensureServer(options, { + server: this.server, + serverPromise: this.serverPromise, }); - - const shared = { - baseUrl: startedBaseUrl, - child, - ...(authHeader ? { authHeader } : {}), - } satisfies SharedServerState; - this.server = shared; - return shared; + this.server = result.state; + return result.state; })(); try { @@ -1304,14 +577,6 @@ export class KiloServerManager extends EventEmitter { } } - private async createClient(options: OpencodeClientOptions): Promise { - // Kilo is a fork of OpenCode and exposes the same HTTP+SSE API, - // so we reuse the OpenCode SDK client to talk to the Kilo server. - const sdkModuleId = "@opencode-ai/sdk/v2/client"; - const sdk = (await import(sdkModuleId)) as KiloSdkModule; - return sdk.createOpencodeClient(options); - } - private async startStream(context: KiloSessionContext): Promise { try { const result = await context.client.event.subscribe( @@ -1325,7 +590,7 @@ export class KiloServerManager extends EventEmitter { if (context.streamAbortController.signal.aborted) { break; } - this.handleEvent(context, event); + handleEvent(this, context, event); } } catch (cause) { if (context.streamAbortController.signal.aborted) { @@ -1339,543 +604,35 @@ export class KiloServerManager extends EventEmitter { updatedAt: nowIso(), lastError: message, }; - const failedTurnId = context.activeTurnId; this.emitRuntimeEvent({ type: "runtime.error", eventId: eventId("kilo-stream-error"), provider: PROVIDER, threadId: context.threadId, createdAt: nowIso(), - ...(failedTurnId ? { turnId: failedTurnId } : {}), + ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), payload: { message, class: "transport_error", }, }); - if (failedTurnId) { - this.emitRuntimeEvent({ - type: "turn.completed", - eventId: eventId("kilo-stream-error-turn-completed"), - provider: PROVIDER, - threadId: context.threadId, - createdAt: nowIso(), - turnId: failedTurnId, - payload: { - state: "failed", - errorMessage: message, - }, - }); - context.activeTurnId = undefined; - } - } - } - - private handleEvent(context: KiloSessionContext, event: KiloEvent): void { - switch (event.type) { - case "session.status": - this.handleSessionStatusEvent(context, event); - return; - case "session.error": - this.handleSessionErrorEvent(context, event); - return; - case "permission.asked": - this.handlePermissionAskedEvent(context, event); - return; - case "permission.replied": - this.handlePermissionRepliedEvent(context, event); - return; - case "question.asked": - this.handleQuestionAskedEvent(context, event); - return; - case "question.replied": - this.handleQuestionRepliedEvent(context, event); - return; - case "question.rejected": - this.handleQuestionRejectedEvent(context, event); - return; - case "message.part.updated": - this.handleMessagePartUpdatedEvent(context, event); - return; - case "message.part.delta": - this.handleMessagePartDeltaEvent(context, event); - return; - case "todo.updated": - this.handleTodoUpdatedEvent(context, event); - return; - } - } - - private handleSessionStatusEvent(context: KiloSessionContext, event: EventSessionStatus): void { - const { sessionID: sessionId, status } = event.properties; - if (sessionId !== context.providerSessionId) { - return; - } - const statusType = status.type; - - if (statusType === "busy") { - context.session = { - ...context.session, - status: "running", - updatedAt: nowIso(), - }; - this.emitRuntimeEvent({ - type: "session.state.changed", - eventId: eventId("kilo-status-busy"), - provider: PROVIDER, - threadId: context.threadId, - createdAt: nowIso(), - ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), - payload: { - state: "running", - }, - raw: { - source: "kilo.server.event", - messageType: statusType, - payload: event, - }, - }); - return; - } - - if (statusType === "retry") { - this.emitRuntimeEvent({ - type: "session.state.changed", - eventId: eventId("kilo-status-retry"), - provider: PROVIDER, - threadId: context.threadId, - createdAt: nowIso(), - ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), - payload: { - state: "waiting", - reason: "retry", - detail: event, - }, - raw: { - source: "kilo.server.event", - messageType: statusType, - payload: event, - }, - }); - return; - } - - if (statusType === "idle") { - const completedAt = nowIso(); - const turnId = context.activeTurnId; - const lastError = context.lastError; - context.activeTurnId = undefined; - context.lastError = undefined; - context.session = { - ...stripTransientSessionFields(context.session), - status: lastError ? "error" : "ready", - updatedAt: completedAt, - ...(lastError ? { lastError } : {}), - }; - - this.emitRuntimeEvent({ - type: "session.state.changed", - eventId: eventId("kilo-status-idle"), - provider: PROVIDER, - threadId: context.threadId, - createdAt: completedAt, - ...(turnId ? { turnId } : {}), - payload: { - state: lastError ? "error" : "ready", - ...(lastError ? { reason: lastError } : {}), - detail: event, - }, - raw: { - source: "kilo.server.event", - messageType: statusType, - payload: event, - }, - }); - - if (turnId) { - this.emitRuntimeEvent({ - type: "turn.completed", - eventId: eventId("kilo-turn-completed"), - provider: PROVIDER, - threadId: context.threadId, - createdAt: completedAt, - turnId, - payload: { - state: lastError ? "failed" : "completed", - ...(lastError ? { errorMessage: lastError } : {}), - }, - raw: { - source: "kilo.server.event", - messageType: statusType, - payload: event, - }, - }); - } - } - } - - private handleSessionErrorEvent(context: KiloSessionContext, event: EventSessionError): void { - const { sessionID: sessionId, error } = event.properties; - if (sessionId && sessionId !== context.providerSessionId) { - return; - } - const errorMessage = sessionErrorMessage(error) ?? "Kilo session error"; - context.lastError = errorMessage; - context.session = { - ...stripTransientSessionFields(context.session), - status: "error", - updatedAt: nowIso(), - lastError: errorMessage, - }; - this.emitRuntimeEvent({ - type: "runtime.error", - eventId: eventId("kilo-session-error"), - provider: PROVIDER, - threadId: context.threadId, - createdAt: nowIso(), - ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), - payload: { - message: errorMessage, - class: "provider_error", - }, - raw: { - source: "kilo.server.event", - messageType: "session.error", - payload: event, - }, - }); - } - - private handlePermissionAskedEvent( - context: KiloSessionContext, - event: EventPermissionAsked, - ): void { - const { id: requestIdValue, sessionID: sessionId, permission } = event.properties; - if (sessionId !== context.providerSessionId) { - return; - } - const requestType = toOpencodeRequestType(permission); - const requestId = ApprovalRequestId.makeUnsafe(requestIdValue); - context.pendingPermissions.set(requestId, { requestId, requestType }); - this.emitRuntimeEvent({ - type: "request.opened", - eventId: eventId("kilo-request-opened"), - provider: PROVIDER, - threadId: context.threadId, - createdAt: nowIso(), - ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), - requestId: RuntimeRequestId.makeUnsafe(requestId), - payload: { - requestType, - detail: permission, - args: event.properties, - }, - raw: { - source: "kilo.server.permission", - messageType: "permission.asked", - payload: event, - }, - }); - } - - private handlePermissionRepliedEvent( - context: KiloSessionContext, - event: EventPermissionReplied, - ): void { - const { requestID: requestIdValue, sessionID: sessionId, reply } = event.properties; - if (sessionId !== context.providerSessionId) { - return; - } - const pending = context.pendingPermissions.get(requestIdValue); - context.pendingPermissions.delete(requestIdValue); - this.emitRuntimeEvent({ - type: "request.resolved", - eventId: eventId("kilo-request-resolved"), - provider: PROVIDER, - threadId: context.threadId, - createdAt: nowIso(), - ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), - requestId: RuntimeRequestId.makeUnsafe(requestIdValue), - payload: { - requestType: pending?.requestType ?? "unknown", - decision: reply, - resolution: event.properties, - }, - raw: { - source: "kilo.server.permission", - messageType: "permission.replied", - payload: event, - }, - }); - } - - private handleQuestionAskedEvent(context: KiloSessionContext, event: EventQuestionAsked): void { - const { - id: requestIdValue, - sessionID: sessionId, - questions: askedQuestions, - } = event.properties; - if (sessionId !== context.providerSessionId) { - return; - } - const questions = askedQuestions.map((question: QuestionInfo, index) => ({ - answerIndex: index, - id: `${requestIdValue}:${index}`, - header: question.header, - question: question.question, - options: question.options.map((option) => ({ - label: option.label, - description: option.description, - })), - })); - const runtimeQuestions = questions.map((question) => ({ - id: question.id, - header: question.header, - question: question.question, - options: question.options, - })); - - const requestId = ApprovalRequestId.makeUnsafe(requestIdValue); - context.pendingQuestions.set(requestId, { - requestId, - questionIds: questions.map((question) => question.id), - questions, - }); - this.emitRuntimeEvent({ - type: "user-input.requested", - eventId: eventId("kilo-user-input-requested"), - provider: PROVIDER, - threadId: context.threadId, - createdAt: nowIso(), - ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), - requestId: RuntimeRequestId.makeUnsafe(requestId), - payload: { - questions: runtimeQuestions, - }, - raw: { - source: "kilo.server.question", - messageType: "question.asked", - payload: event, - }, - }); - } - - private handleQuestionRepliedEvent( - context: KiloSessionContext, - event: EventQuestionReplied, - ): void { - const { - requestID: requestIdValue, - sessionID: sessionId, - answers: answerArrays, - } = event.properties; - if (sessionId !== context.providerSessionId) { - return; - } - const pending = context.pendingQuestions.get(requestIdValue); - context.pendingQuestions.delete(requestIdValue); - const answers = Object.fromEntries( - (pending?.questions ?? []).map((question) => { - const answer = answerArrays[question.answerIndex]; - if (!answer) { - return [question.id, ""]; - } - return [question.id, answer.filter((value) => value.length > 0)]; - }), - ); - this.emitRuntimeEvent({ - type: "user-input.resolved", - eventId: eventId("kilo-user-input-resolved"), - provider: PROVIDER, - threadId: context.threadId, - createdAt: nowIso(), - ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), - requestId: RuntimeRequestId.makeUnsafe(requestIdValue), - payload: { - answers, - }, - raw: { - source: "kilo.server.question", - messageType: "question.replied", - payload: event, - }, - }); - } - - private handleQuestionRejectedEvent( - context: KiloSessionContext, - event: EventQuestionRejected, - ): void { - const { requestID: requestIdValue, sessionID: sessionId } = event.properties; - if (sessionId !== context.providerSessionId) { - return; - } - context.pendingQuestions.delete(requestIdValue); - this.emitRuntimeEvent({ - type: "user-input.resolved", - eventId: eventId("kilo-user-input-rejected"), - provider: PROVIDER, - threadId: context.threadId, - createdAt: nowIso(), - ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), - requestId: RuntimeRequestId.makeUnsafe(requestIdValue), - payload: { - answers: {}, - }, - raw: { - source: "kilo.server.question", - messageType: "question.rejected", - payload: event, - }, - }); - } - - private handleMessagePartUpdatedEvent( - context: KiloSessionContext, - event: EventMessagePartUpdated, - ): void { - const { part } = event.properties; - if (part.sessionID !== context.providerSessionId) { - return; - } - if (part.type === "text") { - context.partStreamById.set(part.id, { kind: "text", streamKind: "assistant_text" }); - return; - } - if (part.type === "reasoning") { - context.partStreamById.set(part.id, { kind: "reasoning", streamKind: "reasoning_text" }); - return; - } - - if (part.type === "tool") { - this.handleToolPartUpdatedEvent(context, event, part); - } - } - - private handleToolPartUpdatedEvent( - context: KiloSessionContext, - event: EventMessagePartUpdated, - part: KiloToolPart, - ): void { - const previous = context.partStreamById.get(part.id); - const title = toolStateTitle(part.state); - const detail = toolStateDetail(part.state); - const lifecycleType = toToolLifecycleEventType(previous, part.state.status); - - context.partStreamById.set(part.id, { kind: "tool" }); - this.emitRuntimeEvent({ - type: lifecycleType, - eventId: eventId(`kilo-tool-${lifecycleType.replace(".", "-")}`), - provider: PROVIDER, - threadId: context.threadId, - createdAt: nowIso(), - ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), - itemId: RuntimeItemId.makeUnsafe(part.id), - payload: { - itemType: toToolItemType(part.tool), - ...(lifecycleType !== "item.updated" - ? { - status: lifecycleType === "item.completed" ? "completed" : "inProgress", - } - : {}), - title: toToolTitle(part.tool), - ...(detail ? { detail } : {}), - data: { - item: part, - }, - }, - raw: { - source: "kilo.server.event", - messageType: "message.part.updated", - payload: event, - }, - }); - - if ((part.state.status === "completed" || part.state.status === "error") && title) { this.emitRuntimeEvent({ - type: "tool.summary", - eventId: eventId("kilo-tool-summary"), + type: "session.exited", + eventId: eventId("kilo-session-exited-error"), provider: PROVIDER, threadId: context.threadId, createdAt: nowIso(), - ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), - itemId: RuntimeItemId.makeUnsafe(part.id), payload: { - summary: `${part.tool}: ${title}`, - precedingToolUseIds: [part.id], - }, - raw: { - source: "kilo.server.event", - messageType: "message.part.updated", - payload: event, + reason: message, + exitKind: "error", + recoverable: false, }, }); } } - private handleMessagePartDeltaEvent( - context: KiloSessionContext, - event: EventMessagePartDelta, - ): void { - const { sessionID, partID: partId, delta } = event.properties; - if (sessionID !== context.providerSessionId) { - return; - } - if (!context.activeTurnId || delta.length === 0) { - return; - } - const partState = context.partStreamById.get(partId); - if (partState?.kind === "tool") { - return; - } - this.emitRuntimeEvent({ - type: "content.delta", - eventId: eventId("kilo-content-delta"), - provider: PROVIDER, - threadId: context.threadId, - createdAt: nowIso(), - turnId: context.activeTurnId, - itemId: RuntimeItemId.makeUnsafe(partId), - payload: { - streamKind: partState?.streamKind ?? "assistant_text", - delta, - }, - raw: { - source: "kilo.server.event", - messageType: "message.part.delta", - payload: event, - }, - }); - } - - private handleTodoUpdatedEvent(context: KiloSessionContext, event: EventTodoUpdated): void { - const { sessionID, todos } = event.properties; - if (sessionID !== context.providerSessionId || !context.activeTurnId) { - return; - } - const plan = todos.map((todo) => ({ - step: todo.content, - status: toPlanStepStatus(todo.status), - })); - this.emitRuntimeEvent({ - type: "turn.plan.updated", - eventId: eventId("kilo-plan-updated"), - provider: PROVIDER, - threadId: context.threadId, - createdAt: nowIso(), - turnId: context.activeTurnId, - payload: { - plan, - }, - raw: { - source: "kilo.server.event", - messageType: "todo.updated", - payload: event, - }, - }); - } - - private emitRuntimeEvent(event: ProviderRuntimeEvent): void { - this.emit("event", event); + emitRuntimeEvent(event: KiloProviderRuntimeEvent): void { + this.emit("event", event as unknown as ProviderRuntimeEvent); } } diff --git a/apps/server/src/provider/Layers/KiloAdapter.test.ts b/apps/server/src/provider/Layers/KiloAdapter.test.ts index 69c69c69f2..ad9eaa08d7 100644 --- a/apps/server/src/provider/Layers/KiloAdapter.test.ts +++ b/apps/server/src/provider/Layers/KiloAdapter.test.ts @@ -13,7 +13,7 @@ import { type ProviderUserInputAnswers, } from "@t3tools/contracts"; import { it, vi } from "@effect/vitest"; -import { Effect, Fiber, Stream } from "effect"; +import { Effect, Stream } from "effect"; import { KiloServerManager } from "../../kiloServerManager.ts"; import { KiloAdapter } from "../Services/KiloAdapter.ts"; @@ -145,7 +145,6 @@ layer("KiloAdapterLive", (it) => { it.effect("forwards manager runtime events through the adapter stream", () => Effect.gen(function* () { const adapter = yield* KiloAdapter; - const eventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); const event = { type: "content.delta", @@ -161,8 +160,13 @@ layer("KiloAdapterLive", (it) => { }, } as unknown as ProviderRuntimeEvent; + // Emit first — the event is buffered in the unbounded queue via the + // listener that was registered during layer construction. manager.emit("event", event); - const received = yield* Fiber.join(eventFiber); + + // Now consume the head. Since the queue already has an item, this + // resolves immediately without a race condition. + const received = yield* Stream.runHead(adapter.streamEvents); assert.equal(received._tag, "Some"); if (received._tag !== "Some") { From 39d48d2facf360544d51488d6d63d07d4a0a1bf8 Mon Sep 17 00:00:00 2001 From: sherlock Date: Mon, 16 Mar 2026 09:40:04 +0530 Subject: [PATCH 5/5] fix: address round 2 review feedback - Use toSorted() instead of sort() on copied arrays (unicorn lint rule) - Validate numTurns in rollbackThread (positive integer, within bounds) - Add sessionID filtering to vcs.branch.updated and file.edited handlers - Fix misleading ensureServer catch comment (caller handles cleanup) --- apps/server/src/kilo/eventHandlers.ts | 6 ++++++ apps/server/src/kilo/serverLifecycle.ts | 4 ++-- apps/server/src/kilo/utils.ts | 2 +- apps/server/src/kiloServerManager.ts | 10 +++++++++- apps/server/src/opencode/eventHandlers.ts | 6 ++++++ apps/server/src/opencode/serverLifecycle.ts | 4 ++-- apps/server/src/opencode/utils.ts | 2 +- apps/server/src/opencodeServerManager.ts | 10 +++++++++- 8 files changed, 36 insertions(+), 8 deletions(-) diff --git a/apps/server/src/kilo/eventHandlers.ts b/apps/server/src/kilo/eventHandlers.ts index cbaf9b1308..54addcf407 100644 --- a/apps/server/src/kilo/eventHandlers.ts +++ b/apps/server/src/kilo/eventHandlers.ts @@ -754,6 +754,9 @@ function handleVcsBranchUpdatedEvent( context: KiloSessionContext, event: EventVcsBranchUpdated, ): void { + if (event.properties.sessionID && event.properties.sessionID !== context.providerSessionId) { + return; + } emitter.emitRuntimeEvent({ type: "thread.metadata.updated", eventId: eventId("kilo-vcs-branch-updated"), @@ -777,6 +780,9 @@ function handleFileEditedEvent( context: KiloSessionContext, event: EventFileEdited, ): void { + if (event.properties.sessionID && event.properties.sessionID !== context.providerSessionId) { + return; + } emitter.emitRuntimeEvent({ type: "files.persisted", eventId: eventId("kilo-file-edited"), diff --git a/apps/server/src/kilo/serverLifecycle.ts b/apps/server/src/kilo/serverLifecycle.ts index 021fcb9bd3..4297a73295 100644 --- a/apps/server/src/kilo/serverLifecycle.ts +++ b/apps/server/src/kilo/serverLifecycle.ts @@ -62,8 +62,8 @@ export async function ensureServer( const state = await serverPromise; return { state, serverPromise }; } catch (error) { - // Clear the promise so next call will retry instead of re-awaiting the - // same rejected promise. + // Propagate the error — the caller's finally block clears serverPromise + // when this.server is still undefined, enabling retry on next call. throw error; } } diff --git a/apps/server/src/kilo/utils.ts b/apps/server/src/kilo/utils.ts index feb93babc2..4b94851ae7 100644 --- a/apps/server/src/kilo/utils.ts +++ b/apps/server/src/kilo/utils.ts @@ -164,7 +164,7 @@ export function parseProviderModels( >, connectedIds?: ReadonlySet, ): ReadonlyArray { - const sorted = [...providers].sort((a, b) => { + const sorted = providers.toSorted((a, b) => { const nameA = a.name || a.id; const nameB = b.name || b.id; return nameA.localeCompare(nameB); diff --git a/apps/server/src/kiloServerManager.ts b/apps/server/src/kiloServerManager.ts index 9c8f1fd0d5..acd11e61ee 100644 --- a/apps/server/src/kiloServerManager.ts +++ b/apps/server/src/kiloServerManager.ts @@ -449,14 +449,22 @@ export class KiloServerManager extends EventEmitter { } async rollbackThread(threadId: ThreadId, numTurns = 1): Promise { + if (!Number.isInteger(numTurns) || numTurns < 1) { + throw new Error(`Invalid numTurns (${numTurns}) — must be a positive integer`); + } const context = this.requireSession(threadId); const ids = context.messageIds; if (ids.length === 0) { throw new Error(`No tracked messages for Kilo thread '${threadId}' — cannot rollback`); } + if (numTurns >= ids.length) { + throw new Error( + `Cannot rollback ${numTurns} turns — only ${ids.length} tracked message(s) available`, + ); + } // Target the message just before the last `numTurns` messages. // Each message ID in the tracked list corresponds to one assistant turn. - const targetIndex = Math.max(0, ids.length - numTurns - 1); + const targetIndex = ids.length - numTurns - 1; const targetMessageId = ids[targetIndex]!; await readJsonData( context.client.session.revert({ diff --git a/apps/server/src/opencode/eventHandlers.ts b/apps/server/src/opencode/eventHandlers.ts index 5a56a3ab92..f426479e85 100644 --- a/apps/server/src/opencode/eventHandlers.ts +++ b/apps/server/src/opencode/eventHandlers.ts @@ -754,6 +754,9 @@ function handleVcsBranchUpdatedEvent( context: OpenCodeSessionContext, event: EventVcsBranchUpdated, ): void { + if (event.properties.sessionID && event.properties.sessionID !== context.providerSessionId) { + return; + } emitter.emitRuntimeEvent({ type: "thread.metadata.updated", eventId: eventId("opencode-vcs-branch-updated"), @@ -777,6 +780,9 @@ function handleFileEditedEvent( context: OpenCodeSessionContext, event: EventFileEdited, ): void { + if (event.properties.sessionID && event.properties.sessionID !== context.providerSessionId) { + return; + } emitter.emitRuntimeEvent({ type: "files.persisted", eventId: eventId("opencode-file-edited"), diff --git a/apps/server/src/opencode/serverLifecycle.ts b/apps/server/src/opencode/serverLifecycle.ts index 9424365cd3..a8727963a7 100644 --- a/apps/server/src/opencode/serverLifecycle.ts +++ b/apps/server/src/opencode/serverLifecycle.ts @@ -61,8 +61,8 @@ export async function ensureServer( const state = await serverPromise; return { state, serverPromise }; } catch (error) { - // Clear the promise so next call will retry instead of re-awaiting the - // same rejected promise. + // Propagate the error — the caller's finally block clears serverPromise + // when this.server is still undefined, enabling retry on next call. throw error; } } diff --git a/apps/server/src/opencode/utils.ts b/apps/server/src/opencode/utils.ts index 0d11ee839b..0e548eb1e4 100644 --- a/apps/server/src/opencode/utils.ts +++ b/apps/server/src/opencode/utils.ts @@ -164,7 +164,7 @@ export function parseProviderModels( >, connectedIds?: ReadonlySet, ): ReadonlyArray { - const sorted = [...providers].sort((a, b) => { + const sorted = providers.toSorted((a, b) => { const nameA = a.name || a.id; const nameB = b.name || b.id; return nameA.localeCompare(nameB); diff --git a/apps/server/src/opencodeServerManager.ts b/apps/server/src/opencodeServerManager.ts index b8fddae3c3..ec50e9aea4 100644 --- a/apps/server/src/opencodeServerManager.ts +++ b/apps/server/src/opencodeServerManager.ts @@ -454,14 +454,22 @@ export class OpenCodeServerManager extends EventEmitter { } async rollbackThread(threadId: ThreadId, numTurns = 1): Promise { + if (!Number.isInteger(numTurns) || numTurns < 1) { + throw new Error(`Invalid numTurns (${numTurns}) — must be a positive integer`); + } const context = this.requireSession(threadId); const ids = context.messageIds; if (ids.length === 0) { throw new Error(`No tracked messages for OpenCode thread '${threadId}' — cannot rollback`); } + if (numTurns >= ids.length) { + throw new Error( + `Cannot rollback ${numTurns} turns — only ${ids.length} tracked message(s) available`, + ); + } // Target the message just before the last `numTurns` messages. // Each message ID in the tracked list corresponds to one assistant turn. - const targetIndex = Math.max(0, ids.length - numTurns - 1); + const targetIndex = ids.length - numTurns - 1; const targetMessageId = ids[targetIndex]!; await readJsonData( context.client.session.revert({