From fabee483f7a20c700c14bdb6dccc8ba78c22bc0e Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 16 Aug 2026 06:19:02 +0800 Subject: [PATCH] fix(hook): apply prompt-hook timeout, surface dropped HookCommand fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit issue #286 — HookCommand schema accepted fields that every executor dropped: - promptHandler now applies entry.timeout (same idiom as command/mcp/http; the header doc promised timeout for every hook type but prompt was the only executor never enforcing it — expiry degrades to the existing non-blocking warn) - detectUnsupportedFields now flags allowedEnvVars / statusMessage / per-command once (zero consumers; only entry-level _sessionEntry?.once is read) so configs warn instead of silently no-op; timeout stays unflagged (now fully honored) Test: red-first in test/hook/warn-unsupported.test.ts (flagging), 146 hook tests green. --- packages/opencode/src/hook/settings.ts | 21 ++++++++++++++++++- .../test/hook/warn-unsupported.test.ts | 12 +++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/hook/settings.ts b/packages/opencode/src/hook/settings.ts index 44b67c0983..89dd6c587e 100644 --- a/packages/opencode/src/hook/settings.ts +++ b/packages/opencode/src/hook/settings.ts @@ -968,6 +968,16 @@ export function detectUnsupportedFields( for (const m of matchers) { for (const h of m.hooks ?? []) { if (h.shell !== undefined) unsupported.push({ field: "shell", value: h.shell, eventName }) + // issue #286 — schema-accepted but executor-dropped fields. Surfaced + // here instead of silently swallowed: allowedEnvVars/statusMessage have + // zero consumers anywhere; per-command `once` is never read (only the + // entry-level _sessionEntry?.once is consumed). `timeout` is NOT + // flagged — every handler type applies it (incl. prompt). + if (h.allowedEnvVars !== undefined) + unsupported.push({ field: "allowedEnvVars", value: h.allowedEnvVars, eventName }) + if (h.statusMessage !== undefined) + unsupported.push({ field: "statusMessage", value: h.statusMessage, eventName }) + if (h.once !== undefined) unsupported.push({ field: "once", value: h.once, eventName }) // `if` is implemented (condition-filter); async/asyncRewake implemented. // All 5 known types now have handlers; type-level unsupported set is empty by design. } @@ -1537,10 +1547,19 @@ const promptHandler: HookHandler = { schema: HookJSONOutputZodSchema, } satisfies Parameters[0] + // issue #286 — the header doc promises `timeout` for every hook type, + // but promptHandler was the only executor never applying it. Honor it + // with the same idiom as command/mcp/http; on expiry the TimeoutException + // is captured by Effect.exit below and degrades to the non-blocking warn. + const timeoutMs = entry.timeout ? entry.timeout * 1000 : DEFAULT_TIMEOUT_MS + const llmExit = yield* Effect.tryPromise({ try: () => generateObject(params).then((r) => r.object), catch: (e) => e, - }).pipe(Effect.exit) + }).pipe( + Effect.timeout(timeoutMs), + Effect.exit, + ) if (llmExit._tag === "Failure") { log.warn("prompt hook failed (non-blocking)", { error: String(llmExit.cause) }) diff --git a/packages/opencode/test/hook/warn-unsupported.test.ts b/packages/opencode/test/hook/warn-unsupported.test.ts index f258cf6682..23ea587871 100644 --- a/packages/opencode/test/hook/warn-unsupported.test.ts +++ b/packages/opencode/test/hook/warn-unsupported.test.ts @@ -38,4 +38,16 @@ describe("detectUnsupportedFields", () => { expect(detectUnsupportedFields(undefined)).toEqual([]) expect(detectUnsupportedFields({})).toEqual([]) }) + + // GOAL-FP/issue #286: HookCommand fields accepted by the schema but dropped + // by every executor must be surfaced, not silently swallowed. `timeout` for + // type "prompt" is implemented (excluded here); allowedEnvVars/statusMessage + // have zero consumers anywhere, and per-command `once` is never read (only + // the entry-level _sessionEntry?.once is consumed). + test("allowedEnvVars / statusMessage / per-command once are flagged (dropped by executors)", () => { + const unsupported = detectUnsupportedFields( + hooks({ allowedEnvVars: ["FOO"], statusMessage: "hi", once: true }), + ) + expect(unsupported.map((u) => u.field).sort()).toEqual(["allowedEnvVars", "once", "statusMessage"]) + }) })