From 271fb9d56989b54a76413136baec7655695828a1 Mon Sep 17 00:00:00 2001 From: Pedro Pombeiro Date: Tue, 18 Aug 2026 22:42:06 +0200 Subject: [PATCH 1/2] fix(homeassistant): surface question reply failures The question reply swallowed every failure with an empty catch, so a failed reply was indistinguishable from a successful one. Any error now goes to the opencode log via client.app.log, covering rejections, thrown errors, a set error field, and non-2xx status codes, as well as an option index that falls outside the offered options. The raw post method is also no longer extracted from its owner. It is called as rawClient.post(...) so the receiver is preserved. The generated client builds post as a closure rather than a this-dependent method, so the previous unbound call did work, but relying on that is fragile and a mock cannot detect the difference. Reply failures remain non-fatal: reporting is best effort and never rejects, so a logging outage cannot break the plugin. --- packages/homeassistant/src/index.ts | 61 +++++++++++++++++++++++++---- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/packages/homeassistant/src/index.ts b/packages/homeassistant/src/index.ts index 97d6e7a..42cdf09 100644 --- a/packages/homeassistant/src/index.ts +++ b/packages/homeassistant/src/index.ts @@ -243,6 +243,32 @@ export const HomeAssistantPlugin: Plugin = async ({ client, directory }) => { return undefined; } + function describeError(error: unknown): string | undefined { + if (error === undefined || error === null) return undefined; + if (error instanceof Error) return error.message; + if (typeof error === 'object') { + try { + return JSON.stringify(error); + } catch { + return '[unserializable error]'; + } + } + return String(error); + } + + async function report(message: string, error: unknown): Promise { + const detail = describeError(error); + await client.app + .log({ + body: { + service: 'opencode-homeassistant', + level: 'error', + message: detail ? `${message}: ${detail}` : message, + }, + }) + .catch(() => {}); + } + async function answerQuestion(requestID: string, waiting: WaitingDetail): Promise { if (activePermissionPolls.has(requestID)) return; @@ -250,18 +276,39 @@ export const HomeAssistantPlugin: Plugin = async ({ client, directory }) => { if (result?.kind !== 'question') return; const label = waiting.questions?.[0]?.options?.[result.optionIndex]?.label; - if (label === undefined) return; + if (label === undefined) { + await report( + `question ${requestID}: option index ${result.optionIndex} out of range`, + undefined, + ); + return; + } - const post = (client as unknown as RawPostClient)._client?.post; - if (!post) return; + const rawClient = (client as unknown as RawPostClient)._client; + if (!rawClient?.post) { + await report(`question ${requestID}: SDK client exposes no raw post method`, undefined); + return; + } - await Promise.resolve( - post({ + try { + const response = (await rawClient.post({ url: '/question/{requestID}/reply', path: { requestID }, body: { answers: [[label]] }, - }), - ).catch(() => {}); + })) as { error?: unknown; response?: { status?: number } } | undefined; + + if (response?.error !== undefined) { + await report(`question ${requestID}: reply rejected`, response.error); + return; + } + + const status = response?.response?.status; + if (typeof status === 'number' && (status < 200 || status >= 300)) { + await report(`question ${requestID}: reply returned HTTP ${status}`, undefined); + } + } catch (error) { + await report(`question ${requestID}: reply threw`, error); + } } const tracker = createAgentStateTracker({ From 63bf0b7d1dacf9bc340fc5a1bd8690481406af12 Mon Sep 17 00:00:00 2001 From: Pedro Pombeiro Date: Tue, 18 Aug 2026 22:53:05 +0200 Subject: [PATCH 2/2] feat(homeassistant): trace the question reply lifecycle A failed remote answer was indistinguishable from a successful one, so diagnosis relied on guessing which step broke. Every stage of the reply now reports to the opencode log: poll start, config resolution, matched or foreign responses, cancellation, timeout, index resolution and the reply request itself. These outcomes are now distinguishable in the log: poll never started, Home Assistant config unavailable, entity unreadable, response never observed, response for a different request, poll canceled, timed out, invalid index, out-of-range index, reply failed and reply succeeded. Progress goes to debug and only real faults to error, so a working setup stays quiet. Observed helper values are summarised as kind plus request ID rather than logged verbatim, keeping arbitrary entity content out of logs. pollForResponse now returns an explicit outcome instead of undefined so callers can tell these cases apart. --- packages/homeassistant/src/index.ts | 115 ++++++++++++++++++++++------ 1 file changed, 93 insertions(+), 22 deletions(-) diff --git a/packages/homeassistant/src/index.ts b/packages/homeassistant/src/index.ts index 42cdf09..2cb345a 100644 --- a/packages/homeassistant/src/index.ts +++ b/packages/homeassistant/src/index.ts @@ -36,6 +36,8 @@ type PollResult = | { kind: 'question'; optionIndex: number } | { kind: 'invalid' }; +type PollOutcome = PollResult | { kind: 'no-config' } | { kind: 'canceled' } | { kind: 'timeout' }; + interface RawPostClient { _client?: { post?: (options: { url: string; path?: Record; body?: unknown }) => unknown; @@ -94,6 +96,12 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +function describeForeignState(state: string): string { + const segments = state.split(':'); + if (segments[0] === 'question') return `kind=question, requestID=${segments[1] ?? '(empty)'}`; + return `kind=permission, id=${segments[0] ?? '(empty)'}`; +} + function parseResponse(state: string, requestId: string): PollResult | undefined { const segments = state.split(':'); @@ -214,25 +222,63 @@ export const HomeAssistantPlugin: Plugin = async ({ client, directory }) => { }; } - async function pollForResponse(requestId: string): Promise { + async function pollForResponse(requestId: string): Promise { const ha = resolveHaConfig(); - if (!ha) return undefined; + if (!ha) { + await report( + `${requestId}: Home Assistant config unavailable (haApiUrl, haToken or ${'${ENV_VAR}'} expansion missing), not polling`, + undefined, + ); + return { kind: 'no-config' }; + } const timeoutMs = (config.permissionTimeout ?? DEFAULT_PERMISSION_TIMEOUT) * 1000; const deadline = Date.now() + timeoutMs; activePermissionPolls.add(requestId); + await trace(`${requestId}: poll started, entity=${ha.entity}, timeout=${timeoutMs}ms`); + + let polls = 0; + let mismatches = 0; + let fetchFailures = 0; try { while (Date.now() < deadline) { - if (repliedPermissions.delete(requestId)) return undefined; + if (repliedPermissions.delete(requestId)) { + await trace(`${requestId}: poll canceled after ${polls} polls (answered elsewhere)`); + return { kind: 'canceled' }; + } + polls += 1; const entity = await fetchHaEntity(ha.apiUrl, ha.token, ha.entity); - if (entity && entity.state) { + if (!entity) { + fetchFailures += 1; + if (fetchFailures === 1) { + await report( + `${requestId}: cannot read entity ${ha.entity} from Home Assistant`, + undefined, + ); + } + } else if (entity.state) { const result = parseResponse(entity.state, requestId); + if (result?.kind === 'invalid') { + await report( + `${requestId}: response present but option index is not a valid index`, + undefined, + ); + await setHaEntity(ha.apiUrl, ha.token, ha.entity, ''); + return result; + } if (result) { + await trace(`${requestId}: matched ${result.kind} response after ${polls} polls`); await setHaEntity(ha.apiUrl, ha.token, ha.entity, ''); return result; } + mismatches += 1; + if (mismatches === 1) { + await trace( + `${requestId}: observed a response for a different request (${describeForeignState(entity.state)})`, + ); + } } await sleep(POLL_INTERVAL_MS); } @@ -240,7 +286,11 @@ export const HomeAssistantPlugin: Plugin = async ({ client, directory }) => { activePermissionPolls.delete(requestId); } - return undefined; + await report( + `${requestId}: timed out after ${timeoutMs}ms (${polls} polls, ${mismatches} responses for other requests, ${fetchFailures} failed reads)`, + undefined, + ); + return { kind: 'timeout' }; } function describeError(error: unknown): string | undefined { @@ -256,40 +306,58 @@ export const HomeAssistantPlugin: Plugin = async ({ client, directory }) => { return String(error); } - async function report(message: string, error: unknown): Promise { - const detail = describeError(error); + async function log(level: 'debug' | 'error', message: string): Promise { await client.app - .log({ - body: { - service: 'opencode-homeassistant', - level: 'error', - message: detail ? `${message}: ${detail}` : message, - }, - }) + .log({ body: { service: 'opencode-homeassistant', level, message } }) .catch(() => {}); } + async function report(message: string, error: unknown): Promise { + const detail = describeError(error); + await log('error', detail ? `${message}: ${detail}` : message); + } + + async function trace(message: string): Promise { + await log('debug', message); + } + async function answerQuestion(requestID: string, waiting: WaitingDetail): Promise { - if (activePermissionPolls.has(requestID)) return; + if (activePermissionPolls.has(requestID)) { + await trace(`${requestID}: already polling, not starting a second poll`); + return; + } + + const optionCount = waiting.questions?.[0]?.options?.length ?? 0; + await trace(`${requestID}: answerQuestion started, ${optionCount} options offered`); const result = await pollForResponse(requestID); - if (result?.kind !== 'question') return; + if (result.kind !== 'question') { + if (result.kind === 'permission') { + await report( + `${requestID}: expected a question response but got a permission one`, + undefined, + ); + } + return; + } const label = waiting.questions?.[0]?.options?.[result.optionIndex]?.label; if (label === undefined) { await report( - `question ${requestID}: option index ${result.optionIndex} out of range`, + `${requestID}: option index ${result.optionIndex} is out of range (${optionCount} options offered)`, undefined, ); return; } + await trace(`${requestID}: option index ${result.optionIndex} resolved to a label`); const rawClient = (client as unknown as RawPostClient)._client; if (!rawClient?.post) { - await report(`question ${requestID}: SDK client exposes no raw post method`, undefined); + await report(`${requestID}: SDK client exposes no raw post method`, undefined); return; } + await trace(`${requestID}: sending reply`); try { const response = (await rawClient.post({ url: '/question/{requestID}/reply', @@ -298,16 +366,19 @@ export const HomeAssistantPlugin: Plugin = async ({ client, directory }) => { })) as { error?: unknown; response?: { status?: number } } | undefined; if (response?.error !== undefined) { - await report(`question ${requestID}: reply rejected`, response.error); + await report(`${requestID}: reply rejected`, response.error); return; } const status = response?.response?.status; if (typeof status === 'number' && (status < 200 || status >= 300)) { - await report(`question ${requestID}: reply returned HTTP ${status}`, undefined); + await report(`${requestID}: reply returned HTTP ${status}`, undefined); + return; } + + await trace(`${requestID}: reply accepted (HTTP ${status ?? 'unknown'})`); } catch (error) { - await report(`question ${requestID}: reply threw`, error); + await report(`${requestID}: reply threw`, error); } } @@ -331,7 +402,7 @@ export const HomeAssistantPlugin: Plugin = async ({ client, directory }) => { } const result = await pollForResponse(waiting.id); - if (result?.kind !== 'permission') return; + if (result.kind !== 'permission') return; const apiResponse = result.response === 'allow' ? 'once' : 'reject'; await client .postSessionIdPermissionsPermissionId({