diff --git a/server/protocol-adapters/hermes-adapter.ts b/server/protocol-adapters/hermes-adapter.ts index 9a9e2e253..d7885c385 100644 --- a/server/protocol-adapters/hermes-adapter.ts +++ b/server/protocol-adapters/hermes-adapter.ts @@ -13,17 +13,31 @@ import { createLogger } from '../logger.js'; const logger = createLogger('hermes-adapter'); -interface HermesEvent { - type: string; - data?: Record; +interface SseEvent { + event?: string; + data: Record; +} + +function parseToolArguments(value: unknown): Record { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + if (typeof value !== 'string' || !value.trim()) return {}; + try { + const parsed = JSON.parse(value) as unknown; + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return { raw: value }; + } } /** * Hermes protocol adapter. * - * Spawns `hermes gateway run` as a per-session lightweight daemon, - * discovers the gateway port, then consumes the SSE event stream - * and drives the agent via REST calls. + * Spawns `hermes gateway run` with its local API server enabled, then drives + * the agent through Hermes' OpenAI-compatible Responses streaming endpoint. */ export class HermesProtocolAdapter extends BaseProtocolAdapter { readonly agentType = 'hermes'; @@ -32,13 +46,15 @@ export class HermesProtocolAdapter extends BaseProtocolAdapter { private _status: AdapterStatus = 'disconnected'; private _config: AdapterConfig | null = null; private _process: ChildProcess | null = null; - private _gatewayPort = 0; - private _gatewayHost = '127.0.0.1'; - private _sseAbortController: AbortController | null = null; + private _processExitCode: number | null = null; + private _processOutputBuffer = ''; + private _apiPort = 0; + private _apiHost = '127.0.0.1'; private _messageAbortController: AbortController | null = null; private _turnCounter = 0; private _currentTurnId: string | null = null; - private _apiToken: string | null = null; + private _apiKey: string | null = null; + private _lastResponseId: string | null = null; get status(): AdapterStatus { return this._status; @@ -49,25 +65,35 @@ export class HermesProtocolAdapter extends BaseProtocolAdapter { async connect(config: AdapterConfig): Promise { this._config = config; this._status = 'connecting'; + this._processExitCode = null; + this._processOutputBuffer = ''; + this._messageAbortController = null; + this._currentTurnId = null; + this._lastResponseId = null; + this._turnCounter = 0; - // Resolve auth token from framework override or extra config - this._apiToken = - (config.extra?.['apiToken'] as string | undefined) ?? null; + this._apiKey = + (config.extra?.['apiToken'] as string | undefined) ?? + crypto.randomBytes(24).toString('hex'); - // Find an open port for the gateway - this._gatewayPort = await getPort(); + this._apiPort = await getPort(); + this._apiHost = + (config.extra?.['host'] as string | undefined) ?? '127.0.0.1'; // Build spawn command — allow commandOverride via extra config for tests const command = (config.extra?.['command'] as string | undefined) ?? 'hermes'; - const defaultArgs = ['gateway', 'run', '--port', String(this._gatewayPort)]; - const args = ((config.extra?.['args'] as string[] | undefined) ?? defaultArgs).map( - (arg) => arg.replace(/\{\{PORT\}\}/g, String(this._gatewayPort)) - ); - const env: Record = {}; - if (this._apiToken) { - env['HERMES_API_TOKEN'] = this._apiToken; - } + const defaultArgs = ['gateway', 'run', '--accept-hooks', '--replace']; + const args = ( + (config.extra?.['args'] as string[] | undefined) ?? defaultArgs + ).map((arg) => arg.replace(/\{\{PORT\}\}/g, String(this._apiPort))); + const env: Record = { + API_SERVER_ENABLED: '1', + API_SERVER_HOST: this._apiHost, + API_SERVER_PORT: String(this._apiPort), + API_SERVER_KEY: this._apiKey, + HERMES_ACCEPT_HOOKS: '1', + }; this._process = spawn(command, args, { cwd: config.cwd, @@ -75,7 +101,16 @@ export class HermesProtocolAdapter extends BaseProtocolAdapter { stdio: ['pipe', 'pipe', 'pipe'], }); + const captureProcessOutput = (chunk: Buffer): void => { + this._processOutputBuffer = ( + this._processOutputBuffer + chunk.toString() + ).slice(-2000); + }; + this._process.stdout?.on('data', captureProcessOutput); + this._process.stderr?.on('data', captureProcessOutput); + this._process.on('exit', (code) => { + this._processExitCode = code; logger.info(`[hermes] process exited with code ${code}`); if (this._status === 'connected') { this._status = 'error'; @@ -100,26 +135,8 @@ export class HermesProtocolAdapter extends BaseProtocolAdapter { }); }); - // Wait for gateway HTTP health endpoint to become available await this.waitForGateway(); - // Start SSE consumer - this._sseAbortController = new AbortController(); - this.consumeSse().catch((err) => { - if (err instanceof Error && err.name !== 'AbortError') { - logger.error('Hermes SSE error:', err); - if (this._status === 'connected') { - this._status = 'error'; - this.fire({ - type: 'chat:error', - kind: 'protocol', - message: 'Hermes SSE connection error', - retryable: true, - }); - } - } - }); - this._status = 'connected'; this.fire({ type: 'chat:session-started', @@ -130,10 +147,10 @@ export class HermesProtocolAdapter extends BaseProtocolAdapter { } protected async onDisconnect(): Promise { - this._sseAbortController?.abort(); this._messageAbortController?.abort(); - this._sseAbortController = null; this._messageAbortController = null; + this._currentTurnId = null; + this._lastResponseId = null; if (this._process) { try { @@ -167,62 +184,125 @@ export class HermesProtocolAdapter extends BaseProtocolAdapter { this._messageAbortController = new AbortController(); this._currentTurnId = turnId; - const url = `${this.baseUrl()}/session/${encodeURIComponent(sessionId)}/prompt`; - const res = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(this._apiToken ? { Authorization: `Bearer ${this._apiToken}` } : {}), - }, - body: JSON.stringify({ text: content }), - signal: this._messageAbortController.signal, - }); - - if (!res.ok) { - throw new Error(`Hermes sendMessage failed: ${res.status}`); - } - this.fire({ type: 'chat:session-status', status: 'active' }); this.fire({ type: 'chat:turn-started', turnId, turnIndex: this._turnCounter++, }); + + const body: Record = { + input: content, + stream: true, + store: true, + session_id: sessionId, + }; + if (this._lastResponseId) { + body['previous_response_id'] = this._lastResponseId; + } + + try { + const url = `${this.baseUrl()}/v1/responses`; + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(this._apiKey ? { Authorization: `Bearer ${this._apiKey}` } : {}), + }, + body: JSON.stringify(body), + signal: this._messageAbortController.signal, + }); + + if (!res.ok) { + throw new Error(`Hermes sendMessage failed: ${res.status}`); + } + if (!res.body) { + throw new Error( + 'Hermes sendMessage failed: streaming response has no body' + ); + } + + await this.consumeResponsesSse(res.body); + } catch (err) { + const isAbort = err instanceof Error && err.name === 'AbortError'; + if (isAbort) { + this._lastResponseId = null; + this.fire({ + type: 'chat:turn-completed', + turnId, + reason: 'interrupted', + durationMs: 0, + toolCallCount: 0, + messageCount: 0, + }); + this.fire({ type: 'chat:session-status', status: 'idle' }); + this._currentTurnId = null; + return; + } else { + this.fire({ + type: 'chat:error', + kind: 'protocol', + message: + err instanceof Error ? err.message : 'Hermes sendMessage failed', + retryable: true, + turnId, + }); + this.fire({ + type: 'chat:turn-completed', + turnId, + reason: 'failed', + durationMs: 0, + toolCallCount: 0, + messageCount: 0, + }); + this.fire({ type: 'chat:session-status', status: 'error' }); + } + this._currentTurnId = null; + throw err; + } finally { + this._messageAbortController = null; + } } async interrupt(_turnId: string): Promise { + this._messageAbortController?.abort(); const sessionId = this._config?.sessionId; if (!sessionId) return; - - const url = `${this.baseUrl()}/session/${encodeURIComponent(sessionId)}/abort`; - await fetch(url, { - method: 'POST', - ...(this._messageAbortController - ? { signal: this._messageAbortController.signal } - : {}), - }).catch(() => {}); + try { + await fetch( + `${this.baseUrl()}/session/${encodeURIComponent(sessionId)}/abort`, + { method: 'POST' } + ); + } catch (err) { + logger.warn('Failed to send Hermes abort request:', err); + } } async respondToApproval( requestId: string, decision: 'allow' | 'allow-always' | 'deny' ): Promise { - const allow = decision === 'allow' || decision === 'allow-always'; - const url = `${this.baseUrl()}/permission/${encodeURIComponent(requestId)}/${allow ? 'allow' : 'deny'}`; - await fetch(url, { - method: 'POST', - ...(this._messageAbortController - ? { signal: this._messageAbortController.signal } - : {}), - }).catch(() => {}); + const action = decision === 'deny' ? 'deny' : 'allow'; + const res = await fetch( + `${this.baseUrl()}/permission/${encodeURIComponent(requestId)}/${action}`, + { method: 'POST' } + ); + if (!res.ok) { + throw new Error(`Hermes approval response failed: ${res.status}`); + } + this.fire({ + type: 'chat:approval-response', + requestId, + decision, + respondedBy: 'user', + turnId: this._currentTurnId ?? 'turn-0', + }); } async respondToInput( _requestId: string, - answers: Record + _answers: Record ): Promise { - const firstAnswer = Object.values(answers)[0]?.[0]; - if (!firstAnswer) return; // Hermes gateway does not currently support structured input questions // via REST; this is a no-op. } @@ -247,7 +327,7 @@ export class HermesProtocolAdapter extends BaseProtocolAdapter { // ── Helpers ─────────────────────────────────────────────────────────────── private baseUrl(): string { - return `http://${this._gatewayHost}:${this._gatewayPort}`; + return `http://${this._apiHost}:${this._apiPort}`; } private async waitForGateway(): Promise { @@ -256,14 +336,24 @@ export class HermesProtocolAdapter extends BaseProtocolAdapter { while (Date.now() < deadline) { try { - const res = await fetch(healthUrl, { signal: AbortSignal.timeout(500) }); + const res = await fetch(healthUrl, { + signal: AbortSignal.timeout(500), + }); if (res.ok) { - logger.info('Hermes gateway ready on port', this._gatewayPort); + logger.info('Hermes API server ready on port', this._apiPort); return; } } catch { // expected while gateway is starting } + if (this._processExitCode !== null) { + const output = this._processOutputBuffer.trim(); + throw new Error( + `Hermes gateway exited before API server became ready (code ${this._processExitCode})${ + output ? `: ${output}` : '' + }` + ); + } await new Promise((r) => setTimeout(r, 200)); } @@ -272,22 +362,14 @@ export class HermesProtocolAdapter extends BaseProtocolAdapter { ); } - private async consumeSse(): Promise { - const url = `${this.baseUrl()}/events`; - const res = await fetch(url, { - ...(this._sseAbortController - ? { signal: this._sseAbortController.signal } - : {}), - }); - - if (!res.ok) { - throw new Error(`Hermes SSE endpoint returned ${res.status}`); - } - if (!res.body) throw new Error('SSE response has no body'); - - const reader = res.body.getReader(); + private async consumeResponsesSse( + body: ReadableStream + ): Promise { + const reader = body.getReader(); const decoder = new TextDecoder(); let buffer = ''; + let eventName: string | undefined; + let eventData = ''; while (true) { const { done, value } = await reader.read(); @@ -295,156 +377,163 @@ export class HermesProtocolAdapter extends BaseProtocolAdapter { buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() ?? ''; - - let eventData = ''; for (const line of lines) { - if (line.startsWith('data:')) { + if (line.startsWith('event:')) { + eventName = line.slice(6).trim(); + } else if (line.startsWith('data:')) { const dataLine = line.slice(5).trim(); eventData = eventData ? eventData + '\n' + dataLine : dataLine; } else if (line.trim() === '' && eventData) { try { - const data = JSON.parse(eventData) as HermesEvent; - this.mapHermesEvent(data); + const data = JSON.parse(eventData) as Record; + this.mapResponsesEvent( + eventName ? { event: eventName, data } : { data } + ); } catch (err) { logger.debug('Failed to parse Hermes SSE event:', err); } + eventName = undefined; eventData = ''; } } } } - private mapHermesEvent(event: HermesEvent): void { - const handler = this._eventHandlers[event.type]; - if (handler) { - handler.call(this, event); - } else { - logger.debug('Unhandled Hermes event:', event.type); - } - } - - private readonly _eventHandlers: Record< - string, - ((event: HermesEvent) => void) | undefined - > = { - token: (event) => { - const turnId = this._currentTurnId ?? 'turn-0'; - const token = String(event.data?.['token'] ?? ''); - if (!token) return; - this.fire({ - type: 'chat:text-delta', - turnId, - messageId: `msg-${turnId}`, - delta: token, - }); - }, - - thinking: (event) => { - const turnId = this._currentTurnId ?? 'turn-0'; - const content = String(event.data?.['content'] ?? ''); - if (!content) return; - this.fire({ - type: 'chat:reasoning', - turnId, - messageId: `msg-${turnId}`, - content, - isDelta: Boolean(event.data?.['isDelta'] ?? true), - }); - }, - - tool_start: (event) => { - const turnId = this._currentTurnId ?? 'turn-0'; - const tool = event.data?.['tool'] as Record | undefined; - this.fire({ - type: 'chat:tool-call', - turnId, - toolCallId: String(event.data?.['toolCallId'] ?? crypto.randomUUID()), - toolName: String(tool?.['name'] ?? event.data?.['toolName'] ?? 'unknown'), - description: String(tool?.['description'] ?? ''), - input: (tool?.['input'] ?? event.data?.['input'] ?? {}) as Record< - string, - unknown - >, - status: 'running', - }); - }, - - tool_end: (event) => { - const turnId = this._currentTurnId ?? 'turn-0'; - const result = event.data?.['result'] as Record | undefined; - const errorVal = result?.['error'] ?? event.data?.['error']; - this.fire({ - type: 'chat:tool-result', - turnId, - toolCallId: String(event.data?.['toolCallId'] ?? 'tool-0'), - toolName: String( - event.data?.['toolName'] ?? result?.['toolName'] ?? 'unknown' - ), - status: errorVal ? 'error' : 'completed', - output: String(result?.['output'] ?? event.data?.['output'] ?? ''), - durationMs: Number(result?.['durationMs'] ?? event.data?.['durationMs'] ?? 0), - ...(errorVal ? { error: String(errorVal) } : {}), - }); - }, - - approval_request: (event) => { - const turnId = this._currentTurnId ?? 'turn-0'; - this.fire({ - type: 'chat:approval-request', - turnId, - requestId: String(event.data?.['requestId'] ?? 'req-0'), - kind: 'permission', - toolName: String(event.data?.['toolName'] ?? 'unknown'), - description: String(event.data?.['description'] ?? ''), - target: String(event.data?.['target'] ?? ''), - }); - this.fire({ - type: 'chat:session-status', - status: 'idle', - waitingOn: 'approval', - }); - }, - - done: () => { - if (this._currentTurnId) { + private mapResponsesEvent(event: SseEvent): void { + const type = + typeof event.data['type'] === 'string' ? event.data['type'] : event.event; + const turnId = this._currentTurnId ?? 'turn-0'; + + switch (type) { + case 'response.created': { + const response = event.data['response'] as + | Record + | undefined; + const responseId = response?.['id']; + if (typeof responseId === 'string') { + this._lastResponseId = responseId; + } + break; + } + case 'response.output_text.delta': { + const delta = event.data['delta']; + if (typeof delta === 'string' && delta) { + this.fire({ + type: 'chat:text-delta', + turnId, + messageId: `msg-${turnId}`, + delta, + }); + } + break; + } + case 'response.output_item.added': { + const item = event.data['item'] as Record | undefined; + if (item?.['type'] !== 'function_call') break; this.fire({ - type: 'chat:turn-completed', - turnId: this._currentTurnId, - reason: 'completed', - durationMs: 0, - toolCallCount: 0, - messageCount: 1, + type: 'chat:tool-call', + turnId, + toolCallId: String( + item['call_id'] ?? item['id'] ?? crypto.randomUUID() + ), + toolName: String(item['name'] ?? 'unknown'), + description: '', + input: parseToolArguments(item['arguments']), + status: 'running', }); - this._currentTurnId = null; + break; } - this.fire({ type: 'chat:session-status', status: 'idle' }); - }, - - apperror: (event) => { - const message = String(event.data?.['message'] ?? 'Unknown error'); - this.fire({ - type: 'chat:error', - kind: 'unknown', - message, - retryable: true, - }); - this.fire({ type: 'chat:session-status', status: 'error' }); - }, + case 'response.completed': { + const response = event.data['response'] as + | Record + | undefined; + const responseId = response?.['id']; + if (typeof responseId === 'string') { + this._lastResponseId = responseId; + } + if (this._currentTurnId) { + this.fire({ + type: 'chat:turn-completed', + turnId: this._currentTurnId, + reason: 'completed', + durationMs: 0, + toolCallCount: 0, + messageCount: 1, + }); + this._currentTurnId = null; + } + this.fire({ type: 'chat:session-status', status: 'idle' }); + break; + } + case 'response.failed': { + const response = event.data['response'] as + | Record + | undefined; + const error = response?.['error'] as + | Record + | undefined; + this._lastResponseId = null; + this.fire({ + type: 'chat:error', + kind: 'protocol', + message: String(error?.['message'] ?? 'Hermes response failed'), + retryable: true, + turnId, + }); + if (this._currentTurnId) { + this.fire({ + type: 'chat:turn-completed', + turnId: this._currentTurnId, + reason: 'failed', + durationMs: 0, + toolCallCount: 0, + messageCount: 0, + }); + this._currentTurnId = null; + } + this.fire({ type: 'chat:session-status', status: 'error' }); + break; + } + case 'permission.requested': + case 'permission.asked': { + this.handlePermissionRequested(event); + break; + } + default: + logger.debug('Unhandled Hermes Responses event:', type); + } + } - compressed: (event) => { - this.fire({ - type: 'chat:compaction', - turnId: this._currentTurnId ?? undefined, - summary: String(event.data?.['summary'] ?? ''), - tokensBefore: Number(event.data?.['tokensBefore'] ?? 0), - tokensAfter: Number(event.data?.['tokensAfter'] ?? 0), - }); - }, - }; + private handlePermissionRequested(event: SseEvent): void { + const props = event.data; + const permission = props['permission'] as + | Record + | undefined; + this.fire({ + type: 'chat:approval-request', + turnId: this._currentTurnId ?? 'turn-0', + requestId: String( + props['requestID'] ?? props['requestId'] ?? props['id'] ?? 'req-0' + ), + kind: 'permission', + toolName: String(permission?.['tool'] ?? props['toolName'] ?? 'unknown'), + description: String( + permission?.['description'] ?? props['description'] ?? '' + ), + target: String(permission?.['target'] ?? props['target'] ?? ''), + }); + this.fire({ + type: 'chat:session-status', + status: 'idle', + waitingOn: 'approval', + }); + } /** Helper to build full ChatEvent from partial fields. */ private fire( - partial: { type: import('../../shared/chat-events.js').ChatEvent['type'] } & Record + partial: { + type: import('../../shared/chat-events.js').ChatEvent['type']; + } & Record ): void { const sessionId = this._config?.sessionId ?? ''; this.emit({ diff --git a/server/protocol-adapters/opencode-adapter.ts b/server/protocol-adapters/opencode-adapter.ts index f311f0bb0..5cf176e18 100644 --- a/server/protocol-adapters/opencode-adapter.ts +++ b/server/protocol-adapters/opencode-adapter.ts @@ -1,73 +1,584 @@ +import { spawn } from 'node:child_process'; +import type { ChildProcess } from 'node:child_process'; import crypto from 'node:crypto'; -import type { AdapterConfig } from '../protocol-adapter.js'; -import { installOpenCodeRelayPlugin } from '../opencode-relay.js'; -import { BaseHookAdapter } from './base-hook-adapter.js'; -import type { HookEventPayload } from './base-hook-adapter.js'; -import { strField, objField } from './adapter-utils.js'; +import getPort from 'get-port'; +import { BaseProtocolAdapter } from '../protocol-adapter.js'; +import type { + AdapterConfig, + AdapterStatus, + SessionOptions, + Attachment, +} from '../protocol-adapter.js'; +import type { ChatEvent, ChatEventSource } from '../../shared/chat-events.js'; import { createLogger } from '../logger.js'; const logger = createLogger('opencode-adapter'); -export class OpenCodeProtocolAdapter extends BaseHookAdapter { +interface OpenCodeEvent { + type: string; + properties?: Record; +} + +interface LegacyHookEventPayload { + type: string; + sessionId?: string; + data?: Record; +} + +interface TextPart { + id?: string; + sessionID?: string; + messageID?: string; + type?: string; + text?: string; +} + +function textPartId(part: TextPart): string { + return String(part.id ?? `${part.messageID ?? 'message'}:text`); +} + +/** + * OpenCode web protocol adapter. + * + * OpenCode's own web UI talks to the headless HTTP server, not stdin on the TUI: + * create a session over REST, send `{ parts: [...] }` prompts, and subscribe to + * `/global/event` for streamed message part updates. + */ +export class OpenCodeProtocolAdapter extends BaseProtocolAdapter { readonly agentType = 'opencode'; - protected buildSpawnCommand(config: AdapterConfig): { - command: string; - args: string[]; - env: Record; - } { - return { - command: 'opencode', - args: [], - env: { - RELAY_IDE_URL: `http://127.0.0.1:${config.port}`, - RELAY_IDE_SESSION_ID: config.sessionId, - RELAY_IDE_TOKEN: config.hookToken, - }, + private _status: AdapterStatus = 'disconnected'; + private _config: AdapterConfig | null = null; + private _process: ChildProcess | null = null; + private _processExitCode: number | null = null; + private _processOutputBuffer = ''; + private _apiPort = 0; + private _apiHost = '127.0.0.1'; + private _endpoint = ''; + private _sseAbortController: AbortController | null = null; + private _messageAbortController: AbortController | null = null; + private _turnCounter = 0; + private _currentTurnId: string | null = null; + private _openCodeSessionId: string | null = null; + private _partText = new Map(); + + readonly runtimeOwnership = 'spawned' as const; + + get status(): AdapterStatus { + return this._status; + } + + get process(): ChildProcess | null { + return this._process; + } + + async connect(config: AdapterConfig): Promise { + this._config = config; + this._status = 'connecting'; + this._processExitCode = null; + this._processOutputBuffer = ''; + this._currentTurnId = null; + this._openCodeSessionId = null; + this._partText.clear(); + + this._apiPort = await getPort(); + this._apiHost = + (config.extra?.['host'] as string | undefined) ?? '127.0.0.1'; + this._endpoint = `http://${this._apiHost}:${this._apiPort}`; + + const command = + (config.extra?.['command'] as string | undefined) ?? 'opencode'; + const defaultArgs = [ + 'serve', + '--hostname', + this._apiHost, + '--port', + String(this._apiPort), + ]; + const args = ( + (config.extra?.['args'] as string[] | undefined) ?? defaultArgs + ).map((arg) => arg.replace(/\{\{PORT\}\}/g, String(this._apiPort))); + + const env = { ...process.env }; + delete env['OPENCODE_SERVER_PASSWORD']; + delete env['OPENCODE_SERVER_USERNAME']; + + this._process = spawn(command, args, { + cwd: config.cwd, + env, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + const captureProcessOutput = (chunk: Buffer): void => { + this._processOutputBuffer = ( + this._processOutputBuffer + chunk.toString() + ).slice(-2000); }; + this._process.stdout?.on('data', captureProcessOutput); + this._process.stderr?.on('data', captureProcessOutput); + + this._process.on('exit', (code) => { + this._processExitCode = code; + logger.info(`[opencode] process exited with code ${code}`); + if (this._status === 'connected') { + this._status = 'error'; + this.fire({ + type: 'chat:error', + kind: 'protocol', + message: `OpenCode server exited with code ${code}`, + retryable: true, + }); + this.fire({ type: 'chat:session-status', status: 'disconnected' }); + } + }); + + this._process.on('error', (err) => { + logger.error('[opencode] process error:', err); + this._status = 'error'; + this.fire({ + type: 'chat:error', + kind: 'protocol', + message: err.message, + retryable: false, + }); + }); + + await this.waitForServer(); + this._openCodeSessionId = await this.createOpenCodeSession(); + this.startEventStream(); + + this._status = 'connected'; + this.fire({ + type: 'chat:session-started', + sessionId: config.sessionId, + agentType: this.agentType, + }); + this.fire({ type: 'chat:session-status', status: 'idle' }); } - protected async setupHooks(_config: AdapterConfig): Promise { - const pluginPath = installOpenCodeRelayPlugin(); - logger.info('OpenCode relay plugin installed at', pluginPath); + protected async onDisconnect(): Promise { + this._sseAbortController?.abort(); + this._messageAbortController?.abort(); + this._sseAbortController = null; + this._messageAbortController = null; + + if (this._process) { + try { + this._process.kill('SIGTERM'); + } catch { + /* may already be dead */ + } + this._process = null; + } + this._status = 'disconnected'; } - protected async cleanupHooks(_config: AdapterConfig): Promise { - // Plugin persists across sessions — no cleanup needed + async reconnect(): Promise { + if (!this._config) + throw new Error('Cannot reconnect before initial connect'); + const config = this._config; + await this.disconnect(); + await this.connect(config); } - protected mapHookEvent(payload: HookEventPayload): void { + /** + * Compatibility path for older plugin-style hook events. Web sessions now use + * OpenCode's HTTP/SSE server transport, but the hooks router and legacy unit + * tests can still deliver these events directly. + */ + handleHookEvent(payload: LegacyHookEventPayload): void { switch (payload.type) { - case 'session.started': - logger.debug('OpenCode session.started received'); - break; case 'session.idle': - this.handleIdle(); + this.handleSessionStatus({ + type: 'session.status', + properties: { status: 'idle' }, + }); break; case 'state.changed': - this.handleStateChanged(payload); + if (payload.data?.['status'] === 'error') { + this.fire({ + type: 'chat:error', + kind: 'unknown', + message: String(payload.data['error'] ?? 'OpenCode session error'), + retryable: true, + }); + this.fire({ type: 'chat:session-status', status: 'error' }); + } break; case 'permission.requested': - this.handlePermissionRequested(payload); + this.handlePermissionAsked({ + type: 'permission.asked', + properties: payload.data ?? {}, + }); break; case 'permission.resolved': - this.handlePermissionResolved(); + this.handlePermissionReplied(); break; case 'tool.started': - this.handleToolStarted(payload); + this.handleToolStarted({ + type: 'tool.execute.before', + properties: payload.data ?? {}, + }); break; case 'tool.finished': - this.handleToolFinished(payload); + this.handleToolFinished({ + type: 'tool.execute.after', + properties: payload.data ?? {}, + }); break; case 'telemetry.updated': - this.handleTelemetry(payload); + this.handleTelemetry(payload.data); break; default: - logger.debug('Unhandled OpenCode hook event:', payload.type); + logger.debug('Unhandled legacy OpenCode hook event:', payload.type); + } + } + + async sendMessage( + turnId: string, + content: string, + _attachments?: Attachment[] + ): Promise { + if (!this._openCodeSessionId) throw new Error('No OpenCode session ID'); + + this._messageAbortController = new AbortController(); + this._currentTurnId = turnId; + + const body: Record = { + parts: [{ type: 'text', text: content }], + }; + if (this._config?.model) { + const [providerID, modelID] = this._config.model.split('/', 2); + if (providerID && modelID) { + body['model'] = { providerID, modelID }; + } + } + + try { + const url = `${this._endpoint}/session/${encodeURIComponent( + this._openCodeSessionId + )}/prompt_async`; + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: this._messageAbortController.signal, + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error( + `OpenCode sendMessage failed: ${res.status}${text ? ` ${text}` : ''}` + ); + } + + this.fire({ type: 'chat:session-status', status: 'active' }); + this.fire({ + type: 'chat:turn-started', + turnId, + turnIndex: this._turnCounter++, + }); + } catch (err) { + const isAbort = err instanceof Error && err.name === 'AbortError'; + if (isAbort) { + if (this._currentTurnId) { + this.fire({ + type: 'chat:turn-completed', + turnId: this._currentTurnId, + reason: 'interrupted', + durationMs: 0, + toolCallCount: 0, + messageCount: 0, + }); + } + this._currentTurnId = null; + this.fire({ type: 'chat:session-status', status: 'idle' }); + return; + } + this._currentTurnId = null; + this.fire({ + type: 'chat:error', + kind: 'protocol', + message: + err instanceof Error ? err.message : 'OpenCode sendMessage failed', + retryable: true, + turnId, + }); + this.fire({ type: 'chat:session-status', status: 'error' }); + throw err; + } finally { + this._messageAbortController = null; + } + } + + async interrupt(_turnId: string): Promise { + if (!this._openCodeSessionId) return; + this._messageAbortController?.abort(); + await fetch( + `${this._endpoint}/session/${encodeURIComponent( + this._openCodeSessionId + )}/abort`, + { method: 'POST' } + ).catch(() => {}); + } + + async respondToApproval( + requestId: string, + decision: 'allow' | 'allow-always' | 'deny' + ): Promise { + if (!this._openCodeSessionId) { + throw new Error( + 'Cannot respond to approval before OpenCode session exists' + ); + } + const response = + decision === 'deny' + ? 'reject' + : decision === 'allow-always' + ? 'always' + : 'once'; + const res = await fetch( + `${this._endpoint}/session/${encodeURIComponent( + this._openCodeSessionId + )}/permissions/${encodeURIComponent(requestId)}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ response }), + } + ); + if (!res.ok) { + throw new Error(`OpenCode approval response failed: ${res.status}`); + } + this.fire({ + type: 'chat:approval-response', + requestId, + decision, + respondedBy: 'user', + turnId: this._currentTurnId ?? 'turn-0', + }); + } + + async respondToInput( + requestId: string, + answers: Record + ): Promise { + const firstAnswer = Object.values(answers)[0]?.[0]; + if (!firstAnswer) return; + await fetch( + `${this._endpoint}/question/${encodeURIComponent(requestId)}/reply`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ response: firstAnswer }), + } + ).catch(() => {}); + } + + async createSession( + _cwd: string, + _options?: SessionOptions + ): Promise { + // OpenCode REST sessions are created in connect() via createOpenCodeSession(). + // This returns only Relay's placeholder id for generic session APIs. + return this._config?.sessionId ?? crypto.randomBytes(8).toString('hex'); + } + + async resumeSession(_sessionId: string): Promise { + // no-op; the OpenCode REST session is created on connect. + } + + async forkSession(_sessionId: string): Promise { + // Forking is not exposed by the OpenCode REST transport here; return a + // placeholder id for callers that require one. + return crypto.randomBytes(8).toString('hex'); + } + + private async waitForServer(): Promise { + const healthUrl = `${this._endpoint}/global/health`; + const deadline = Date.now() + 10_000; + + while (Date.now() < deadline) { + try { + const res = await fetch(healthUrl, { + signal: AbortSignal.timeout(500), + }); + if (res.ok) return; + } catch { + // expected while server is starting + } + if (this._processExitCode !== null) { + const output = this._processOutputBuffer.trim(); + if (output.includes('EADDRINUSE')) { + throw new Error( + `OpenCode server failed to bind port ${this._apiPort}: ${output}` + ); + } + throw new Error( + `OpenCode server exited before becoming ready (code ${this._processExitCode})${ + output ? `: ${output}` : '' + }` + ); + } + await new Promise((r) => setTimeout(r, 200)); } + + throw new Error(`OpenCode server did not become ready within 10s`); } - private handleIdle(): void { + private async createOpenCodeSession(): Promise { + const title = this._config?.sessionId + ? `Relay ${this._config.sessionId}` + : 'Relay web session'; + const res = await fetch(`${this._endpoint}/session`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title }), + }); + if (!res.ok) { + throw new Error(`OpenCode session create failed: ${res.status}`); + } + const session = (await res.json()) as Record; + const id = session['id']; + if (typeof id !== 'string' || !id) { + throw new Error('OpenCode session create failed: response missing id'); + } + return id; + } + + private startEventStream(): void { + this._sseAbortController = new AbortController(); + this.consumeSse(`${this._endpoint}/global/event`).catch((err) => { + if (err instanceof Error && err.name === 'AbortError') return; + logger.warn('OpenCode SSE error:', err); + if (this._status === 'connected') { + this._status = 'error'; + this.fire({ + type: 'chat:error', + kind: 'protocol', + message: 'OpenCode SSE connection error', + retryable: true, + }); + this.fire({ type: 'chat:session-status', status: 'error' }); + } + }); + } + + private async consumeSse(url: string): Promise { + const res = await fetch(url, { + ...(this._sseAbortController + ? { signal: this._sseAbortController.signal } + : {}), + }); + if (!res.ok) { + throw new Error( + `OpenCode SSE endpoint returned ${res.status} ${res.statusText}` + ); + } + if (!res.body) throw new Error('SSE response has no body'); + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let eventData = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + + for (const line of lines) { + if (line.startsWith('data:')) { + const dataLine = line.slice(5).trim(); + eventData = eventData ? eventData + '\n' + dataLine : dataLine; + } else if (line.trim() === '' && eventData) { + try { + const data = JSON.parse(eventData) as OpenCodeEvent; + this.mapOpenCodeEvent(data); + } catch (err) { + logger.debug('Failed to parse OpenCode SSE event:', err); + } + eventData = ''; + } + } + } + } + + private mapOpenCodeEvent(event: OpenCodeEvent): void { + if (!this.isCurrentSessionEvent(event)) return; + + switch (event.type) { + case 'session.status': + this.handleSessionStatus(event); + break; + case 'session.error': + this.handleSessionError(event); + break; + case 'message.part.updated': + this.handleMessagePartUpdated(event); + break; + case 'permission.asked': + this.handlePermissionAsked(event); + break; + case 'permission.replied': + this.handlePermissionReplied(); + break; + case 'tool.execute.before': + this.handleToolStarted(event); + break; + case 'tool.execute.after': + this.handleToolFinished(event); + break; + case 'question.asked': + this.handleQuestionAsked(event); + break; + default: + logger.debug('Unhandled OpenCode event:', event.type); + } + } + + private isCurrentSessionEvent(event: OpenCodeEvent): boolean { + const sessionId = this.sessionIdFromEvent(event); + return !sessionId || sessionId === this._openCodeSessionId; + } + + private sessionIdFromEvent(event: OpenCodeEvent): string | undefined { + const props = event.properties ?? {}; + const direct = props['sessionID']; + if (typeof direct === 'string') return direct; + const part = props['part'] as Record | undefined; + if (typeof part?.['sessionID'] === 'string') return part['sessionID']; + const info = props['info'] as Record | undefined; + if (typeof info?.['sessionID'] === 'string') return info['sessionID']; + const message = props['message'] as Record | undefined; + if (typeof message?.['sessionID'] === 'string') return message['sessionID']; + return undefined; + } + + private handleSessionStatus(event: OpenCodeEvent): void { + const status = event.properties?.['status']; + if (status === 'active') { + this.fire({ type: 'chat:session-status', status: 'active' }); + return; + } + if (status === 'error') { + if (this._currentTurnId) { + this.fire({ + type: 'chat:turn-completed', + turnId: this._currentTurnId, + reason: 'failed', + durationMs: 0, + toolCallCount: 0, + messageCount: 0, + }); + this._currentTurnId = null; + } + this.fire({ type: 'chat:session-status', status: 'error' }); + return; + } + if (status !== 'idle') return; if (this._currentTurnId) { this.fire({ type: 'chat:turn-completed', @@ -82,35 +593,72 @@ export class OpenCodeProtocolAdapter extends BaseHookAdapter { this.fire({ type: 'chat:session-status', status: 'idle' }); } - private handleStateChanged(payload: HookEventPayload): void { - if (payload.data?.['status'] !== 'error') return; - const errorMsg = strField(payload.data, 'error', 'Unknown error'); + private handleSessionError(event: OpenCodeEvent): void { this.fire({ type: 'chat:error', kind: 'unknown', - message: errorMsg, + message: String(event.properties?.['error'] ?? 'OpenCode session error'), retryable: true, + turnId: this._currentTurnId ?? undefined, }); + if (this._currentTurnId) { + this.fire({ + type: 'chat:turn-completed', + turnId: this._currentTurnId, + reason: 'failed', + durationMs: 0, + toolCallCount: 0, + messageCount: 0, + }); + this._currentTurnId = null; + } this.fire({ type: 'chat:session-status', status: 'error' }); } - private handlePermissionRequested(payload: HookEventPayload): void { + private handleMessagePartUpdated(event: OpenCodeEvent): void { + const part = event.properties?.['part'] as TextPart | undefined; + if (!part || part.type !== 'text') return; const turnId = this._currentTurnId ?? 'turn-0'; - const permission = objField(payload.data, 'permission'); + const messageId = part.messageID ?? `msg-${turnId}`; + const delta = this.deltaForPart(part, event.properties?.['delta']); + if (!delta) return; this.fire({ - type: 'chat:approval-request', + type: 'chat:text-delta', turnId, - requestId: strField(payload.data, 'requestId', crypto.randomUUID()), - kind: 'permission', - toolName: String( - permission?.['tool'] ?? strField(payload.data, 'toolName', 'unknown') + messageId, + delta, + }); + } + + private deltaForPart(part: TextPart, rawDelta: unknown): string { + const id = textPartId(part); + const next = typeof part.text === 'string' ? part.text : ''; + if (typeof rawDelta === 'string') { + this._partText.set(id, next); + return rawDelta; + } + const prev = this._partText.get(id) ?? ''; + this._partText.set(id, next); + return next.startsWith(prev) ? next.slice(prev.length) : next; + } + + private handlePermissionAsked(event: OpenCodeEvent): void { + const props = event.properties ?? {}; + const permission = props['permission'] as + | Record + | undefined; + this.fire({ + type: 'chat:approval-request', + turnId: this._currentTurnId ?? 'turn-0', + requestId: String( + props['requestID'] ?? props['requestId'] ?? props['id'] ?? 'req-0' ), + kind: 'permission', + toolName: String(permission?.['tool'] ?? props['toolName'] ?? 'unknown'), description: String( - permission?.['description'] ?? strField(payload.data, 'description') - ), - target: String( - permission?.['target'] ?? strField(payload.data, 'target') + permission?.['description'] ?? props['description'] ?? '' ), + target: String(permission?.['target'] ?? props['target'] ?? ''), }); this.fire({ type: 'chat:session-status', @@ -119,24 +667,22 @@ export class OpenCodeProtocolAdapter extends BaseHookAdapter { }); } - private handlePermissionResolved(): void { + private handlePermissionReplied(): void { if (this._currentTurnId) { this.fire({ type: 'chat:session-status', status: 'active' }); } } - private handleToolStarted(payload: HookEventPayload): void { - const turnId = this._currentTurnId ?? 'turn-0'; - const tool = objField(payload.data, 'tool'); + private handleToolStarted(event: OpenCodeEvent): void { + const props = event.properties ?? {}; + const tool = props['tool'] as Record | undefined; this.fire({ type: 'chat:tool-call', - turnId, - toolCallId: strField(payload.data, 'toolCallId', crypto.randomUUID()), - toolName: String( - tool?.['name'] ?? strField(payload.data, 'toolName', 'unknown') - ), + turnId: this._currentTurnId ?? 'turn-0', + toolCallId: String(props['toolCallId'] ?? crypto.randomUUID()), + toolName: String(tool?.['name'] ?? props['toolName'] ?? 'unknown'), description: String(tool?.['description'] ?? ''), - input: (tool?.['input'] ?? payload.data?.['input'] ?? {}) as Record< + input: (tool?.['input'] ?? props['input'] ?? {}) as Record< string, unknown >, @@ -144,31 +690,48 @@ export class OpenCodeProtocolAdapter extends BaseHookAdapter { }); } - private handleToolFinished(payload: HookEventPayload): void { - const turnId = this._currentTurnId ?? 'turn-0'; - const result = objField(payload.data, 'result'); - const tool = objField(payload.data, 'tool'); - const errorVal = result?.['error'] ?? payload.data?.['error']; + private handleToolFinished(event: OpenCodeEvent): void { + const props = event.properties ?? {}; + const result = props['result'] as Record | undefined; + const tool = props['tool'] as Record | undefined; + const errorVal = result?.['error'] ?? props['error']; this.fire({ type: 'chat:tool-result', - turnId, - toolCallId: strField(payload.data, 'toolCallId'), - toolName: String( - tool?.['name'] ?? strField(payload.data, 'toolName', 'unknown') - ), + turnId: this._currentTurnId ?? 'turn-0', + toolCallId: String(props['toolCallId'] ?? 'tool-0'), + toolName: String(tool?.['name'] ?? props['toolName'] ?? 'unknown'), status: errorVal ? 'error' : 'completed', - output: String(result?.['output'] ?? strField(payload.data, 'output')), - durationMs: Number( - result?.['durationMs'] ?? payload.data?.['durationMs'] ?? 0 - ), + output: String(result?.['output'] ?? props['output'] ?? ''), + durationMs: Number(result?.['durationMs'] ?? props['durationMs'] ?? 0), ...(errorVal ? { error: String(errorVal) } : {}), }); } - private handleTelemetry(payload: HookEventPayload): void { - const message = objField(payload.data, 'message'); + private handleQuestionAsked(event: OpenCodeEvent): void { + const props = event.properties ?? {}; + const rawQuestions = (props['questions'] as unknown[]) ?? []; + const questionText = + typeof rawQuestions[0] === 'string' + ? String(rawQuestions[0]) + : 'Agent is asking a question'; + const fields = rawQuestions.map((q, idx) => ({ + id: `q${idx}`, + label: typeof q === 'string' ? q : String(q), + type: 'text' as const, + })); + this.fire({ + type: 'chat:input-request', + turnId: this._currentTurnId ?? 'turn-0', + requestId: String(props['requestID'] ?? 'req-0'), + question: questionText, + fields, + }); + } + + private handleTelemetry(data: Record | undefined): void { + const message = data?.['message'] as Record | undefined; if (!message) return; - const tokens = objField(message, 'tokens'); + const tokens = message['tokens'] as Record | undefined; if (!tokens) return; this.fire({ type: 'chat:telemetry', @@ -183,4 +746,17 @@ export class OpenCodeProtocolAdapter extends BaseHookAdapter { contextWindowSize: 0, }); } + + /** Helper to build full ChatEvent from partial fields. */ + private fire( + partial: { type: ChatEvent['type'] } & Record + ): void { + const sessionId = this._config?.sessionId ?? ''; + this.emit({ + ...partial, + sessionId, + timestamp: new Date().toISOString(), + source: this.agentType as ChatEventSource, + } as ChatEvent); + } } diff --git a/test/fixtures/hermes-gateway-stub.cjs b/test/fixtures/hermes-gateway-stub.cjs index b9474b6d7..8794e00eb 100644 --- a/test/fixtures/hermes-gateway-stub.cjs +++ b/test/fixtures/hermes-gateway-stub.cjs @@ -3,24 +3,23 @@ /** * Mock Hermes Gateway Stub * - * Lightweight HTTP server that mimics the Hermes gateway protocol + * Lightweight HTTP server that mimics the Hermes API server protocol * expected by server/protocol-adapters/hermes-adapter.ts. * - * Usage: node hermes-gateway-stub.js + * Usage: API_SERVER_PORT=1234 node hermes-gateway-stub.js */ const http = require('http'); const url = require('url'); -const port = parseInt(process.argv[2], 10); +const port = parseInt(process.env.API_SERVER_PORT || process.argv[2], 10); if (!port || isNaN(port)) { - console.error('Usage: node hermes-gateway-stub.js '); + console.error( + 'Usage: API_SERVER_PORT=1234 node hermes-gateway-stub.js (or: node hermes-gateway-stub.js )' + ); process.exit(1); } -/** @type {http.ServerResponse | null} */ -let sseClient = null; - const server = http.createServer((req, res) => { const parsed = url.parse(req.url, true); @@ -42,28 +41,26 @@ const server = http.createServer((req, res) => { return; } - // SSE events stream - if (parsed.pathname === '/events') { - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - }); - sseClient = res; - return; - } - - // Send a prompt - if (parsed.pathname.match(/^\/session\/[^/]+\/prompt$/)) { + // OpenAI-compatible Responses streaming endpoint + if (parsed.pathname === '/v1/responses') { let body = ''; req.on('data', (chunk) => (body += chunk)); req.on('end', () => { - const data = JSON.parse(body); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ accepted: true })); - - // Emit mock SSE events after a short delay - setTimeout(() => emitTurn(data.text), 50); + let data; + try { + data = JSON.parse(body); + } catch (err) { + console.error('Invalid Hermes stub request JSON:', err); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'invalid JSON body' })); + return; + } + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + emitTurn(res, data.input); }); return; } @@ -86,26 +83,44 @@ const server = http.createServer((req, res) => { res.end('Not Found'); }); -function sendEvent(event) { - if (!sseClient) return; - sseClient.write(`data: ${JSON.stringify(event)}\n\n`); +function sendEvent(res, event, data) { + res.write(`event: ${event}\n`); + res.write(`data: ${JSON.stringify({ type: event, ...data })}\n\n`); } -function emitTurn(userText) { - const turnId = `turn-${Date.now()}`; +function emitTurn(res, userText) { + const responseId = `resp_${Date.now()}`; + sendEvent(res, 'response.created', { + response: { id: responseId, status: 'in_progress', output: [] }, + }); // Stream a few tokens const words = ['hello', 'from', 'hermes', 'stub']; words.forEach((word, i) => { setTimeout(() => { - sendEvent({ type: 'token', data: { token: word + ' ' } }); + sendEvent(res, 'response.output_text.delta', { delta: word + ' ' }); }, i * 30); }); - // Done event - setTimeout(() => { - sendEvent({ type: 'done', data: {} }); - }, words.length * 30 + 50); + setTimeout( + () => { + sendEvent(res, 'response.completed', { + response: { + id: responseId, + status: 'completed', + output: [ + { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: `echo: ${userText}` }], + }, + ], + }, + }); + res.end(); + }, + words.length * 30 + 50 + ); } server.listen(port, '127.0.0.1', () => { diff --git a/test/fixtures/opencode-serve-stub.cjs b/test/fixtures/opencode-serve-stub.cjs new file mode 100644 index 000000000..51ba940a3 --- /dev/null +++ b/test/fixtures/opencode-serve-stub.cjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node +/** + * Lightweight HTTP server that mimics the OpenCode serve API used by + * server/protocol-adapters/opencode-adapter.ts. + * + * Usage: node opencode-serve-stub.cjs --port + */ + +const http = require('http'); +const url = require('url'); + +const args = process.argv.slice(2); +const portIdx = args.indexOf('--port'); +const port = parseInt( + process.env.OPENCODE_PORT || (portIdx !== -1 ? args[portIdx + 1] : ''), + 10 +); + +if (!port || isNaN(port)) { + console.error('Usage: node opencode-serve-stub.cjs --port '); + process.exit(1); +} + +/** @type {Set} */ +const sseClients = new Set(); + +const sessionId = 'ses_stub'; + +const server = http.createServer((req, res) => { + const parsed = url.parse(req.url, true); + + if (parsed.pathname === '/global/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ healthy: true, version: 'stub' })); + return; + } + + if (parsed.pathname === '/global/event') { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + sseClients.add(res); + sendEvent(res, { + type: 'server.connected', + properties: {}, + }); + req.on('close', () => sseClients.delete(res)); + return; + } + + if (parsed.pathname === '/session' && req.method === 'POST') { + drain(req, () => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ id: sessionId, title: 'Relay test' })); + }); + return; + } + + if ( + parsed.pathname === `/session/${sessionId}/prompt_async` && + req.method === 'POST' + ) { + drain(req, (body) => { + let payload; + try { + payload = JSON.parse(body); + } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'invalid JSON body' })); + return; + } + if ( + !payload || + typeof payload !== 'object' || + !Array.isArray(payload.parts) + ) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'parts is required' })); + return; + } + + res.writeHead(204); + res.end(); + emitTurn(); + }); + return; + } + + if ( + parsed.pathname === `/session/${sessionId}/abort` && + req.method === 'POST' + ) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(true)); + return; + } + + res.writeHead(404); + res.end('Not Found'); +}); + +function drain(req, done) { + let body = ''; + req.on('data', (chunk) => (body += chunk)); + req.on('end', () => done(body)); +} + +function sendEvent(res, event) { + res.write(`data: ${JSON.stringify(event)}\n\n`); +} + +function broadcast(event) { + for (const client of sseClients) { + sendEvent(client, event); + } +} + +function emitTurn() { + const part = { + id: 'part_1', + sessionID: sessionId, + messageID: 'msg_1', + type: 'text', + }; + + broadcast({ + type: 'session.status', + properties: { sessionID: sessionId, status: 'active' }, + }); + + ['hello ', 'from ', 'opencode'].forEach((delta, index) => { + setTimeout(() => { + broadcast({ + type: 'message.part.updated', + properties: { part, delta }, + }); + }, index * 25); + }); + + setTimeout(() => { + broadcast({ + type: 'session.status', + properties: { sessionID: sessionId, status: 'idle' }, + }); + }, 100); +} + +server.listen(port, '127.0.0.1', () => { + console.log(`OpenCode serve stub listening on ${port}`); +}); + +process.on('SIGTERM', () => server.close(() => process.exit(0))); +process.on('SIGINT', () => server.close(() => process.exit(0))); diff --git a/test/opencode-adapter.e2e.test.ts b/test/opencode-adapter.e2e.test.ts new file mode 100644 index 000000000..356103562 --- /dev/null +++ b/test/opencode-adapter.e2e.test.ts @@ -0,0 +1,65 @@ +import { test, expect, vi } from 'vitest'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createWebSession } from '../server/web-session-handler.js'; +import type { Session } from '../server/types.js'; +import type { ChatEvent } from '../shared/chat-events.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const STUB_SCRIPT = path.resolve( + __dirname, + 'fixtures', + 'opencode-serve-stub.cjs' +); + +test('opencode web session sends prompts through serve API and receives streamed events', async () => { + const sessionsMap = new Map(); + const onBackendStateChanged = vi.fn<(session: Session) => void>(); + + const { session } = await createWebSession( + { + agentType: 'opencode', + cwd: __dirname, + repoPath: __dirname, + repoName: 'opencode-test', + branchName: 'main', + displayName: 'OpenCode E2E Test', + port: 3000, + configDir: __dirname, + extra: { + command: process.execPath, + args: [STUB_SCRIPT, '--port', '{{PORT}}'], + }, + }, + sessionsMap, + onBackendStateChanged + ); + + expect(session.mode).toBe('web'); + expect(session.adapterType).toBe('opencode'); + expect(session.agentState).toBe('idle'); + + const events: ChatEvent[] = []; + const unbind = session.adapter.on((evt) => events.push(evt)); + + await session.adapter.sendMessage('turn-1', 'hello opencode'); + + await vi.waitFor( + () => { + expect(session.agentState).toBe('idle'); + expect(events.some((e) => e.type === 'chat:text-delta')).toBe(true); + expect(events.some((e) => e.type === 'chat:turn-completed')).toBe(true); + }, + { timeout: 5000, interval: 25 } + ); + + const text = events + .filter((e) => e.type === 'chat:text-delta') + .map((e) => e.delta) + .join(''); + expect(text).toContain('hello from opencode'); + + unbind(); + await session.adapter.disconnect(); + sessionsMap.delete(session.id); +});