From fa2946e6d2a7bbe333ef94f14a68c8ae7bd962b5 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 3 Jul 2026 21:45:30 +0800 Subject: [PATCH 1/5] feat(hooks): dynamic Active Hooks block + /create-hook command - SettingsHook.list(): read-only scope-tagged summaries over hot-reloaded state - SystemPrompt.hooks(): per-turn rendered block, zero tokens when no hooks - /create-hook command: interactive hook authoring into hooks.json - Remove static AGENTS.md Hooks_START snapshot (replaced by dynamic block) - Fix import-claude-hooks.txt JSON format examples (missing hooks wrapper) - Complete event list (27) and type list (5 incl mcp) in format reference --- AGENTS.md | 8 - packages/opencode/src/command/index.ts | 10 + .../src/command/template/create-hook.txt | 146 +++++++++++++ .../command/template/import-claude-hooks.txt | 96 ++++----- packages/opencode/src/hook/settings.ts | 110 +++++++++- packages/opencode/src/session/prompt.ts | 4 +- packages/opencode/src/session/system.ts | 21 ++ packages/opencode/test/hook/list.test.ts | 197 ++++++++++++++++++ .../opencode/test/permission/next.test.ts | 1 + packages/opencode/test/session/system.test.ts | 70 +++++++ 10 files changed, 599 insertions(+), 64 deletions(-) create mode 100644 packages/opencode/src/command/template/create-hook.txt create mode 100644 packages/opencode/test/hook/list.test.ts diff --git a/AGENTS.md b/AGENTS.md index 07c51dc457..909b34aa79 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -201,11 +201,3 @@ Guiding invariants for adding services, HTTP API routes, or features. The build - Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's provider-turn allowance; a batch of steers resets it once. - Keep EventV2 replay owner claims separate from clustered Session execution ownership. - Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned. - - -## Active Hooks (auto-generated — do not edit between markers) - -No hooks configured. Run `/import-claude-hooks` to migrate from Claude Code config, or manually create `hooks.json` files. - -Full config: `~/.config/opencode/hooks.json` (global) · `.opencode/hooks.json` (project) - diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 676e3ad7c6..8fcaff8dff 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -9,6 +9,7 @@ import { Skill } from "../skill" import PROMPT_INITIALIZE from "./template/initialize.txt" import PROMPT_REVIEW from "./template/review.txt" import PROMPT_IMPORT_HOOKS from "./template/import-claude-hooks.txt" +import PROMPT_CREATE_HOOK from "./template/create-hook.txt" import { LegacyEvent } from "@opencode-ai/schema/legacy-event" type State = { @@ -49,6 +50,7 @@ export const Default = { GOAL: "goal", SUBGOAL: "subgoal", IMPORT_HOOKS: "import-claude-hooks", + CREATE_HOOK: "create-hook", } as const export interface Interface { @@ -111,6 +113,14 @@ export const layer = Layer.effect( subtask: true, hints: [], } + commands[Default.CREATE_HOOK] = { + name: Default.CREATE_HOOK, + description: "Create a new hook interactively and write it to hooks.json", + source: "command", + template: PROMPT_CREATE_HOOK, + subtask: true, + hints: [], + } for (const [name, command] of Object.entries(cfg.command ?? {})) { commands[name] = { diff --git a/packages/opencode/src/command/template/create-hook.txt b/packages/opencode/src/command/template/create-hook.txt new file mode 100644 index 0000000000..b2133ff59a --- /dev/null +++ b/packages/opencode/src/command/template/create-hook.txt @@ -0,0 +1,146 @@ +--- +description: Create a new hook interactively and write it to the correct hooks.json layer +--- + +# Create Hook + +This command guides interactive authoring of a **single new hook entry** into the correct `hooks.json` file. It merge-appends (never overwrites) and validates the event name before writing. + +## Prerequisites + +Load the `configure-hooks` skill first — it documents the canonical 27-event list, the 5 hook types, and the `hooks.json` format. Reference it instead of guessing event names or field shapes. + +## Process + +### 1. Gather inputs interactively + +Ask the user for each field, one at a time: + +**Event** — one of the 27 valid events (see the `configure-hooks` skill for the full list). Tool lifecycle: `PreToolUse`, `PostToolUse`, `PostToolUseFailure`. Permission: `PermissionRequest`, `PermissionDenied`. Session: `Setup`, `SessionStart`, `SessionEnd`, `Stop`, `StopFailure`. Subagents: `SubagentStart`, `SubagentStop`. Prompt/compaction: `UserPromptSubmit`, `PreCompact`, `PostCompact`. Tasks: `TaskCreated`, `TaskCompleted`, `TeammateIdle`. Other: `Notification`, `Elicitation`, `ElicitationResult`, `ConfigChange`, `WorktreeCreate`, `WorktreeRemove`, `InstructionsLoaded`, `CwdChanged`, `FileChanged`. + +**Reject invalid event names before doing anything else** — show the valid list and re-ask. Do NOT write the file for an unknown event. + +**Hook type** — `command` | `mcp` | `http` | `prompt` | `agent`. Ask which action the hook should perform: + +- `command` — run a shell command +- `mcp` — invoke an MCP tool (`mcp____`) +- `http` — POST the event envelope to a URL +- `prompt` — run an LLM call with structured output +- `agent` — run an autonomous sub-agent loop + +**Action field** (depends on type): + +- `command` → the shell command string +- `mcp` → the tool name in `mcp____` format +- `http` → the endpoint URL +- `prompt` / `agent` → the prompt / goal text + +**Matcher** (only for tool-bound events: `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, `PermissionDenied`): + +- Empty / omitted / `*` — matches all tools +- `Bash` — exact tool name +- `Bash|Edit|Write` — pipe-separated list +- Any other string — treated as a regex + +For non-tool events, omit the matcher entirely. + +**Optional fields** — ask if the user wants to set any: + +- `timeout` — seconds before the hook is killed (default 60) +- `statusMessage` — short label shown in the UI while the hook runs + +**Scope** — `project` or `global`: + +- `project` → `.opencode/hooks.json` in the current project (hot-reloads in ~2s) +- `global` → `~/.config/opencode/hooks.json` (requires restart to take effect) + +### 2. Build the JSON entry + +Construct the hook entry matching the `hooks.json` format. The event key maps to an array of matcher blocks; each block has an optional `matcher` and a `hooks` array: + +```json +{ + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { "type": "command", "command": "echo 'hello from hook'", "timeout": 10 } + ] + } + ] +} +``` + +For non-tool events, omit the `matcher` field: + +```json +{ + "Stop": [ + { + "hooks": [ + { "type": "command", "command": "echo 'session stopped'" } + ] + } + ] +} +``` + +### 3. Confirm with the user + +Show the complete JSON entry (including the event name, matcher block, and hook object) and ask for confirmation before writing. If the user wants changes, edit and re-show. + +### 4. Write to hooks.json (merge-append) + +Read the target `hooks.json` first: + +- **Project scope**: `${PROJECT_DIR}/.opencode/hooks.json` +- **Global scope**: `~/.config/opencode/hooks.json` + +Then: + +1. If the file does not exist, create it (create the `.opencode/` directory for project scope if needed). +2. If the file exists, parse it and **merge-append** the new entry: + - If the event key already exists, append the new matcher block to the existing array (do NOT replace existing entries). + - If the event key does not exist, add it with the new matcher block as the only element. + - Preserve all existing entries and any unknown fields (e.g. `$schema`, `version`) — only touch the event key you are adding to. +3. Write the merged JSON back with 2-space indentation. + +Never overwrite the entire file. Never delete existing hooks. + +### 5. Verify the write + +After writing, re-read the file and JSON-parse it to confirm validity: + +- If the parse succeeds, report success. +- If the parse fails, report the error and ask the user to check the file manually. + +### 6. Report hot-reload semantics + +- **Project scope**: "The hook will hot-reload within ~2 seconds — it will be active on the next session turn." +- **Global scope**: "Global hooks load at startup. Restart opencode for this hook to take effect." + +Point the user at the **Active Hooks** block in the system prompt (visible on the next turn for project-scoped hooks) to confirm the hook is live. + +## Example session + +``` +User: /create-hook +Agent: Which event should this hook fire on? (see the configure-hooks skill for all 27 events) +User: PreToolUse +Agent: What type of hook? command / mcp / http / prompt / agent +User: command +Agent: What shell command should run? +User: ./scripts/check.sh +Agent: Matcher (which tools should trigger this)? Empty = all tools. +User: Bash +Agent: Project or global scope? (project hot-reloads in ~2s; global requires restart) +User: project + +Here is the entry I will write to .opencode/hooks.json: +{ "PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "./scripts/check.sh" }] }] } +Proceed? (y/n) +User: y + +✓ Written to .opencode/hooks.json (merge-appended alongside existing hooks). +The hook will hot-reload within ~2 seconds — it will be active on the next session turn. +``` diff --git a/packages/opencode/src/command/template/import-claude-hooks.txt b/packages/opencode/src/command/template/import-claude-hooks.txt index dd54545f74..48482ff498 100644 --- a/packages/opencode/src/command/template/import-claude-hooks.txt +++ b/packages/opencode/src/command/template/import-claude-hooks.txt @@ -69,10 +69,14 @@ Example migration: // Migrated (hooks.json) { - "PreToolUse": [{ - "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/validate.sh" - }] + "PreToolUse": [ + { + "matcher": "*", + "hooks": [ + { "type": "command", "command": "bash ${CLAUDE_PLUGIN_ROOT}/validate.sh" } + ] + } + ] } ``` Note: `${CLAUDE_PLUGIN_ROOT}` is a runtime placeholder that OpenCode will expand based on the hook's location. Since we're moving to `.opencode/`, the expansion will now point there. @@ -84,19 +88,23 @@ Approved hooks go to: - **Global scope** (from `~/.claude/settings.json`): `~/.config/opencode/hooks.json` - **Project scope** (from `.claude/settings.json` or `.claude/settings.local.json`): `.opencode/hooks.json` -Format: top-level event keys (NOT wrapped in `{"hooks": {...}}`). Example: +Format: top-level event keys (NOT wrapped in `{"hooks": {...}}`). Each event maps to an array of matcher blocks; each block wraps an optional `matcher` and a `hooks` array: + ```json { "PreToolUse": [ { - "type": "command", - "command": "bash -c \"echo pre-tool\"" + "matcher": "*", + "hooks": [ + { "type": "command", "command": "bash -c \"echo pre-tool\"" } + ] } ], "PostToolUse": [ { - "type": "http", - "url": "https://api.example.com/hook" + "hooks": [ + { "type": "http", "url": "https://api.example.com/hook" } + ] } ] } @@ -104,27 +112,7 @@ Format: top-level event keys (NOT wrapped in `{"hooks": {...}}`). Example: If the target file already exists, merge (append) the imported hooks rather than overwriting. -### 5. Update AGENTS.md - -Scan the project's `AGENTS.md` for the managed section: - -```markdown - -## Configured Hooks - -### Global Hooks -- **PreToolUse**: `bash -c "echo pre-tool"` (command) - -### Project Hooks -- **PostToolUse**: `https://api.example.com/hook` (http) - -``` - -If the markers don't exist, add them at the end of AGENTS.md with a summary of imported hooks. If they exist, replace the content between them while preserving everything outside. - -The summary should be brief (event + type + one-line description per hook). - -### 6. Report +### 5. Report After migration, report: @@ -133,32 +121,34 @@ After migration, report: - Any hooks that were edited during migration - Deprecation warnings (if hooks were found in .opencode/settings.json) - Reminder: "You can now safely delete .claude/ directories if you no longer use Claude Code with this project." +- The imported hooks are now visible in the **Active Hooks** block of the system prompt (rendered dynamically each turn from the live `hooks.json` state) — no manual AGENTS.md update is needed. ## Hooks.json Format Reference -OpenCode's `hooks.json` uses top-level event keys. Supported events: - -- `PreToolUse`, `PostToolUse` -- `SessionStart`, `SessionEnd` -- `UserPromptSubmit` -- `Stop`, `StopFailure` -- `SubagentStart`, `SubagentStop` -- `TaskCreated`, `TaskCompleted` -- `PermissionRequest`, `PermissionDenied` -- `FileChanged` -- `PreCompact`, `PostCompact` -- `WorktreeCreate`, `WorktreeRemove` -- `ConfigChange` -- `TeammateIdle` -- `InstructionsLoaded` - -Each event maps to an array of hook matchers. Each matcher can have: -- `type`: "command" | "http" | "prompt" | "agent" -- `command`/`url`/`prompt`: the hook action -- `timeout`: optional seconds -- `matcher`: optional regex pattern (tool name matching) - -Merge semantics: concat-append (hooks accumulate, not replace). +OpenCode's `hooks.json` uses top-level event keys (27 events total). For the canonical list with per-event input/output shapes, see the `configure-hooks` skill. + +Supported events: + +- Tool lifecycle: `PreToolUse`, `PostToolUse`, `PostToolUseFailure` +- Permission: `PermissionRequest`, `PermissionDenied` +- Session lifecycle: `Setup`, `SessionStart`, `SessionEnd`, `Stop`, `StopFailure` +- Subagents: `SubagentStart`, `SubagentStop` +- Prompt/compaction: `UserPromptSubmit`, `PreCompact`, `PostCompact` +- Tasks/goals: `TaskCreated`, `TaskCompleted`, `TeammateIdle` +- Other: `Notification`, `Elicitation`, `ElicitationResult`, `ConfigChange`, `WorktreeCreate`, `WorktreeRemove`, `InstructionsLoaded`, `CwdChanged`, `FileChanged` + +Each event maps to an array of **matcher blocks**. Each matcher block has: + +- `matcher`: optional — `"*"` (default, all tools), exact name (`"Bash"`), pipe list (`"Bash|Edit"`), or regex +- `hooks`: array of hook entries + +Each hook entry has: + +- `type`: `"command"` | `"mcp"` | `"http"` | `"prompt"` | `"agent"` +- `command` / `url` / `prompt`: the hook action (field depends on type — `command` for command/mcp, `url` for http, `prompt` for prompt/agent) +- `timeout`: optional seconds (default 60) + +Merge semantics: concat-append (hooks accumulate across layers and within a file, not replace). ## Important Notes diff --git a/packages/opencode/src/hook/settings.ts b/packages/opencode/src/hook/settings.ts index ab61db7227..79c31fbb2a 100644 --- a/packages/opencode/src/hook/settings.ts +++ b/packages/opencode/src/hook/settings.ts @@ -200,6 +200,20 @@ export interface Settings { allowUntrusted?: boolean } +/** + * Read-only render DTO for the dynamic Active Hooks system-prompt block. + * One entry per individual hook command across the merged chain, tagged with + * the layer it came from. Produced by `summarizeChain` and surfaced via + * `SettingsHook.list()` — never re-reads files (reads from hot-reloaded state). + */ +export interface HookSummary { + event: HookEvent + scope: "global" | "project" | "worktree" + type: HookCommand["type"] + descriptor: string + matcher?: string +} + export interface HookJSONOutput { continue?: boolean stopReason?: string @@ -539,6 +553,27 @@ function promptText(entry: HookCommand): string { return entry.prompt ?? entry.command ?? "" } +/** + * Short human-readable description of a hook entry for the Active Hooks block. + * command → first ~60 chars of the command; http → the URL; mcp → the tool + * name; prompt/agent → the first line of the prompt/goal text. + */ +function descriptorFor(entry: HookCommand): string { + switch (entry.type) { + case "command": + return commandText(entry).slice(0, 60) + case "http": + return httpUrl(entry) + case "mcp": + return commandText(entry) + case "prompt": + case "agent": + return promptText(entry).split("\n")[0] + default: + return commandText(entry).slice(0, 60) + } +} + // ── Matcher ───────────────────────────────────────────────────── /** @@ -640,6 +675,48 @@ export function mergeSettings(layers: Settings[]): Settings { return out } +/** + * Produce scope-tagged summaries of the merged hooks chain — one entry per + * individual hook command, tagged with the layer (global/project/worktree) it + * came from. Ordering matches loadChain: global first, then project, then + * worktree. Used by `SettingsHook.list()` so the Active Hooks block reflects + * live, hot-reloaded state without re-reading files on every call. + * + * Exported for unit testing only; not part of the public surface. + */ +export function summarizeChain(directory: string, worktree: string, globalConfig?: string): HookSummary[] { + const home = os.homedir() + const opencodeGlobal = globalConfig ?? (() => { + try { + return Global.Path.config + } catch { + return path.join(home, ".config", "opencode") + } + })() + const candidates: Array<{ scope: "global" | "project" | "worktree"; file: string }> = [ + { scope: "global", file: path.join(opencodeGlobal, "hooks.json") }, + { scope: "project", file: path.join(directory, ".opencode", "hooks.json") }, + ] + if (worktree && worktree !== directory) { + candidates.push({ scope: "worktree", file: path.join(worktree, ".opencode", "hooks.json") }) + } + return candidates.flatMap(({ scope, file }) => { + const data = readJSON(file) + if (!data?.hooks) return [] + return Object.entries(data.hooks).flatMap(([event, matchers]) => + (matchers ?? []).flatMap((m) => + (m.hooks ?? []).map((h) => ({ + event: event as HookEvent, + scope, + type: h.type, + descriptor: descriptorFor(h), + ...(m.matcher && m.matcher !== "*" ? { matcher: m.matcher } : {}), + })), + ), + ) + }) +} + // Exported for unit testing only; not part of the public surface. // `globalConfig` overrides the resolved OpenCode global config dir so tests can // point it at an isolated temp dir instead of the real ~/.config/opencode. @@ -1127,6 +1204,12 @@ interface State { * the "" bucket, preserving the prior global-dedup behavior for those. */ seen: Map> + /** + * Scope-tagged summaries of the currently-effective hooks, computed by + * `summarizeChain` alongside `settings` (same closure, same hot-reload + * watcher). `list()` reads this without touching files. + */ + hooksList: HookSummary[] } export interface Interface { @@ -1134,6 +1217,13 @@ export interface Interface { payload: HookPayload, ctx: TriggerContext, ) => Effect.Effect + /** + * Read-only view of the currently-effective hooks (merged global + project + + * worktree chain), one entry per hook command tagged with its source layer. + * Backed by the same hot-reloaded state `trigger` consults — never re-reads + * files. Empty when no hooks.json layer defines any hook. + */ + readonly list: () => Effect.Effect> } export class Service extends Context.Service()("@opencode/SettingsHook") {} @@ -1485,6 +1575,7 @@ export const layer = Layer.effect( const settings = loadChain(instCtx.directory, instCtx.worktree) const stateObj = { settings, + hooksList: summarizeChain(instCtx.directory, instCtx.worktree, Global.Path.config), cwd: instCtx.directory, seen: new Map>(), } satisfies State @@ -1496,12 +1587,22 @@ export const layer = Layer.effect( // state object, so the mutation is visible without invalidating the // cache. The finalizer closes the watcher when the instance scope is // disposed (same scope-based cleanup discipline as GoalLoop.state). + // + // The reload Effect computes both merged settings and scope-tagged + // summaries in one pass; lastSummaries carries the summaries into the + // onReload callback (watchSettings only threads Settings through). + let lastSummaries: HookSummary[] = stateObj.hooksList const handle = watchSettings( instCtx.directory, instCtx.worktree, - () => Effect.sync(() => loadChain(instCtx.directory, instCtx.worktree)), + () => Effect.sync(() => { + const newSettings = loadChain(instCtx.directory, instCtx.worktree) + lastSummaries = summarizeChain(instCtx.directory, instCtx.worktree, Global.Path.config) + return newSettings + }), (newSettings) => { stateObj.settings = newSettings + stateObj.hooksList = lastSummaries }, Global.Path.config, ) @@ -1770,7 +1871,12 @@ export const layer = Layer.effect( return result }) - return Service.of({ trigger }) + const list = Effect.fn("SettingsHook.list")(function* () { + const s = yield* InstanceState.get(state) + return s.hooksList + }) + + return Service.of({ trigger, list }) }), ) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index c749012492..f8ea0fbcad 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1456,12 +1456,13 @@ export const layer = Layer.effect( yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) - const [skills, env, instructions, mcpInstructions, goalDocs, modelMsgs] = yield* Effect.all([ + const [skills, env, instructions, mcpInstructions, goalDocs, hooksDocs, modelMsgs] = yield* Effect.all([ sys.skills(agent), sys.environment(model), instruction.system().pipe(Effect.orDie), sys.mcp(agent, session.permission), sys.goal(sessionID), + sys.hooks(), MessageV2.toModelMessagesEffect(msgs, model), ]) const system = [ @@ -1470,6 +1471,7 @@ export const layer = Layer.effect( ...(mcpInstructions ? [mcpInstructions] : []), ...(skills ? [skills] : []), ...goalDocs, + ...hooksDocs, ] const format = lastUser.format ?? { type: "text" as const } if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index b1ae4b44eb..0e0cdd3b3a 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -25,6 +25,7 @@ import { MCP } from "@/mcp" import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { Goal } from "@/goal/goal" import { GoalPrompts } from "@/goal/prompts" +import { SettingsHook } from "@/hook/settings" import type { SessionID } from "@/session/schema" export function provider(model: Provider.Model) { @@ -48,6 +49,7 @@ export interface Interface { readonly skills: (agent: Agent.Info) => Effect.Effect readonly mcp: (agent: Agent.Info, permission?: PermissionV1.Ruleset) => Effect.Effect readonly goal: (sessionID: SessionID) => Effect.Effect + readonly hooks: () => Effect.Effect } export class Service extends Context.Service()("@opencode/SystemPrompt") {} @@ -150,6 +152,25 @@ export const layer = Layer.effect( // avoid prompt bloat (spec: no-active-goal-injected-as-terse-note). return [PROMPT_GOAL, GoalPrompts.renderGoalSystemBlock(state)] }), + + // Active Hooks block — dynamic, mirrors goal's economy: no hooks → empty + // array (no header, no placeholder). SettingsHook is resolved at request + // time via serviceOption (per tool-service-resolution spec) so headless / + // test entry points that omit the heavyweight service degrade cleanly. + hooks: Effect.fn("SystemPrompt.hooks")(function* () { + const hookSvc = Option.getOrUndefined(yield* Effect.serviceOption(SettingsHook.Service)) + if (!hookSvc) return [] + const hooks = yield* hookSvc.list() + if (hooks.length === 0) return [] + const MAX = 20 + const shown = hooks.slice(0, MAX) + const lines = [ + "## Active Hooks", + ...shown.map((h) => `- ${h.event} [${h.scope}/${h.type}] ${h.descriptor}`), + ] + if (hooks.length > MAX) lines.push(`… and ${hooks.length - MAX} more (see hooks.json)`) + return [lines.join("\n")] + }), }) }), ) diff --git a/packages/opencode/test/hook/list.test.ts b/packages/opencode/test/hook/list.test.ts new file mode 100644 index 0000000000..5a246cc3af --- /dev/null +++ b/packages/opencode/test/hook/list.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, test } from "bun:test" +import * as fs from "fs/promises" +import os from "os" +import path from "path" +import { summarizeChain } from "@/hook/settings" + +// Unit tests for summarizeChain — the scope-tagged read surface behind +// SettingsHook.list(). Mirrors load-chain.test.ts: isolated temp dirs for the +// global / project / worktree scopes, globalConfig override for determinism. + +async function mktmp(prefix: string): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), `hook-list-${prefix}-`)) +} + +async function writeHooksJson(dir: string, json: unknown): Promise { + const opencodeDir = path.join(dir, ".opencode") + await fs.mkdir(opencodeDir, { recursive: true }) + await fs.writeFile(path.join(opencodeDir, "hooks.json"), JSON.stringify(json)) +} + +async function writeGlobalHooksJson(globalDir: string, json: unknown): Promise { + await fs.mkdir(globalDir, { recursive: true }) + await fs.writeFile(path.join(globalDir, "hooks.json"), JSON.stringify(json)) +} + +describe("summarizeChain — scope tags and ordering", () => { + test("global entries come before project entries with correct scope tags", async () => { + const globalDir = await mktmp("global") + const projectDir = await mktmp("project") + try { + await writeGlobalHooksJson(globalDir, { + PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "global-check.sh" }] }], + }) + await writeHooksJson(projectDir, { + Stop: [{ hooks: [{ type: "command", command: "project-stop.sh" }] }], + }) + + const summaries = summarizeChain(projectDir, "", globalDir) + expect(summaries.length).toBe(2) + // Global layer appended first, project after — same order loadChain merges. + expect(summaries[0].scope).toBe("global") + expect(summaries[0].event).toBe("PreToolUse") + expect(summaries[1].scope).toBe("project") + expect(summaries[1].event).toBe("Stop") + } finally { + await Promise.all([fs.rm(globalDir, { recursive: true, force: true }), fs.rm(projectDir, { recursive: true, force: true })]) + } + }) + + test("no hooks.json layers → empty summary", async () => { + const globalDir = await mktmp("empty-g") + const projectDir = await mktmp("empty-p") + try { + const summaries = summarizeChain(projectDir, "", globalDir) + expect(summaries).toEqual([]) + } finally { + await Promise.all([fs.rm(globalDir, { recursive: true, force: true }), fs.rm(projectDir, { recursive: true, force: true })]) + } + }) + + test("worktree layer appends after project when worktree differs", async () => { + const globalDir = await mktmp("wt-g") + const projectDir = await mktmp("wt-p") + const worktreeDir = await mktmp("wt-w") + try { + await writeHooksJson(projectDir, { Stop: [{ hooks: [{ type: "command", command: "p" }] }] }) + await writeHooksJson(worktreeDir, { Stop: [{ hooks: [{ type: "command", command: "w" }] }] }) + + const summaries = summarizeChain(projectDir, worktreeDir, globalDir) + expect(summaries.map((s) => s.scope)).toEqual(["project", "worktree"]) + } finally { + await Promise.all([fs.rm(globalDir, { recursive: true, force: true }), fs.rm(projectDir, { recursive: true, force: true }), fs.rm(worktreeDir, { recursive: true, force: true })]) + } + }) +}) + +describe("summarizeChain — descriptor derivation by type", () => { + test("command descriptor is the command text", async () => { + const projectDir = await mktmp("desc-cmd") + try { + await writeHooksJson(projectDir, { + PreToolUse: [{ hooks: [{ type: "command", command: "echo hello world" }] }], + }) + const [summary] = summarizeChain(projectDir, "", projectDir) + expect(summary.descriptor).toBe("echo hello world") + } finally { + await fs.rm(projectDir, { recursive: true, force: true }) + } + }) + + test("command descriptor truncates to 60 chars", async () => { + const longCommand = "x".repeat(100) + const projectDir = await mktmp("desc-long") + try { + await writeHooksJson(projectDir, { + PreToolUse: [{ hooks: [{ type: "command", command: longCommand }] }], + }) + const [summary] = summarizeChain(projectDir, "", projectDir) + expect(summary.descriptor.length).toBe(60) + } finally { + await fs.rm(projectDir, { recursive: true, force: true }) + } + }) + + test("http descriptor is the url", async () => { + const projectDir = await mktmp("desc-http") + try { + await writeHooksJson(projectDir, { + PostToolUse: [{ hooks: [{ type: "http", url: "https://example.com/webhook" }] }], + }) + const [summary] = summarizeChain(projectDir, "", projectDir) + expect(summary.descriptor).toBe("https://example.com/webhook") + } finally { + await fs.rm(projectDir, { recursive: true, force: true }) + } + }) + + test("mcp descriptor is the tool name (command field)", async () => { + const projectDir = await mktmp("desc-mcp") + try { + await writeHooksJson(projectDir, { + PreToolUse: [{ hooks: [{ type: "mcp", command: "my-server__my-tool" }] }], + }) + const [summary] = summarizeChain(projectDir, "", projectDir) + expect(summary.descriptor).toBe("my-server__my-tool") + } finally { + await fs.rm(projectDir, { recursive: true, force: true }) + } + }) + + test("prompt descriptor is the first line of the prompt", async () => { + const projectDir = await mktmp("desc-prompt") + try { + await writeHooksJson(projectDir, { + Stop: [{ hooks: [{ type: "prompt", prompt: "Summarize the work done.\nMore detail here." }] }], + }) + const [summary] = summarizeChain(projectDir, "", projectDir) + expect(summary.descriptor).toBe("Summarize the work done.") + } finally { + await fs.rm(projectDir, { recursive: true, force: true }) + } + }) + + test("agent descriptor is the first line of the goal", async () => { + const projectDir = await mktmp("desc-agent") + try { + await writeHooksJson(projectDir, { + Stop: [{ hooks: [{ type: "agent", prompt: "Run a final review.\nCheck tests too." }] }], + }) + const [summary] = summarizeChain(projectDir, "", projectDir) + expect(summary.descriptor).toBe("Run a final review.") + } finally { + await fs.rm(projectDir, { recursive: true, force: true }) + } + }) +}) + +describe("summarizeChain — matcher handling", () => { + test("specific matcher is included in summary", async () => { + const projectDir = await mktmp("match-specific") + try { + await writeHooksJson(projectDir, { + PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "check.sh" }] }], + }) + const [summary] = summarizeChain(projectDir, "", projectDir) + expect(summary.matcher).toBe("Bash") + } finally { + await fs.rm(projectDir, { recursive: true, force: true }) + } + }) + + test("wildcard matcher (*) is omitted", async () => { + const projectDir = await mktmp("match-wild") + try { + await writeHooksJson(projectDir, { + Stop: [{ matcher: "*", hooks: [{ type: "command", command: "stop.sh" }] }], + }) + const [summary] = summarizeChain(projectDir, "", projectDir) + expect(summary.matcher).toBeUndefined() + } finally { + await fs.rm(projectDir, { recursive: true, force: true }) + } + }) + + test("absent matcher is omitted", async () => { + const projectDir = await mktmp("match-absent") + try { + await writeHooksJson(projectDir, { + Stop: [{ hooks: [{ type: "command", command: "stop.sh" }] }], + }) + const [summary] = summarizeChain(projectDir, "", projectDir) + expect(summary.matcher).toBeUndefined() + } finally { + await fs.rm(projectDir, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index ce3e0a07b5..26529f9f0e 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -20,6 +20,7 @@ const noopSettingsHook = Layer.succeed( SettingsHook.Service, SettingsHook.Service.of({ trigger: () => Effect.succeed({ blocked: undefined, permissionDecision: undefined, permissionDecisionReason: undefined, additionalContexts: [], systemMessages: [], hookSpecificOutput: undefined }), + list: () => Effect.succeed([]), }), ) diff --git a/packages/opencode/test/session/system.test.ts b/packages/opencode/test/session/system.test.ts index 548f0028c5..1d2c8312e0 100644 --- a/packages/opencode/test/session/system.test.ts +++ b/packages/opencode/test/session/system.test.ts @@ -6,6 +6,7 @@ import { Skill } from "../../src/skill" import { Permission } from "../../src/permission" import { SystemPrompt } from "../../src/session/system" import { MCP } from "../../src/mcp" +import { SettingsHook, type HookSummary } from "../../src/hook/settings" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { testEffect } from "../lib/effect" @@ -138,4 +139,73 @@ describe("session.system", () => { ) }), ) + + it.effect("hooks block renders Active Hooks when SettingsHook provides entries", () => + Effect.gen(function* () { + const prompt = yield* SystemPrompt.Service + const entries: HookSummary[] = [ + { event: "PreToolUse", scope: "project", type: "command", descriptor: "echo hi", matcher: "Bash" }, + { event: "Stop", scope: "global", type: "http", descriptor: "https://example.com/stop" }, + ] + const output = yield* prompt.hooks().pipe( + Effect.provide( + Layer.mock(SettingsHook.Service, { list: () => Effect.succeed(entries) }), + ), + ) + + expect(output).toEqual([ + [ + "## Active Hooks", + "- PreToolUse [project/command] echo hi", + "- Stop [global/http] https://example.com/stop", + ].join("\n"), + ]) + }), + ) + + it.effect("hooks block is empty when SettingsHook list is empty", () => + Effect.gen(function* () { + const prompt = yield* SystemPrompt.Service + const output = yield* prompt.hooks().pipe( + Effect.provide( + Layer.mock(SettingsHook.Service, { list: () => Effect.succeed([]) }), + ), + ) + + expect(output).toEqual([]) + }), + ) + + it.effect("hooks block is empty when SettingsHook service is absent (degrades cleanly)", () => + Effect.gen(function* () { + const prompt = yield* SystemPrompt.Service + // No SettingsHook layer provided — serviceOption returns None. + const output = yield* prompt.hooks() + + expect(output).toEqual([]) + }), + ) + + it.effect("hooks block caps at 20 entries and reports the remainder", () => + Effect.gen(function* () { + const prompt = yield* SystemPrompt.Service + const entries: HookSummary[] = Array.from({ length: 25 }, (_, i) => ({ + event: "PreToolUse" as const, + scope: "project" as const, + type: "command" as const, + descriptor: `cmd-${i}`, + })) + const output = yield* prompt.hooks().pipe( + Effect.provide( + Layer.mock(SettingsHook.Service, { list: () => Effect.succeed(entries) }), + ), + ) + + const block = output[0] + expect(block).toContain("## Active Hooks") + expect(block).toContain("cmd-0") + expect(block).not.toContain("cmd-24") + expect(block).toContain("… and 5 more (see hooks.json)") + }), + ) }) From 9d7302005b8b383b1df927d1eaddce8f9cdf9d39 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 3 Jul 2026 21:46:46 +0800 Subject: [PATCH 2/5] fix(hooks): dedup skill descriptions + hot-reload mtime detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Export CustomizeOpencodeDescription / ConfigureHooksDescription from core, import in opencode instead of hardcoding (descriptions had already drifted) - hot-reload: m > prev → m !== prev to cover cp -p / touch -t restoring older mtimes; reload is idempotent so the broader trigger is safe - Add mtime-decrease regression test - Include hooks-leftover-issues.md analysis document --- hooks-leftover-issues.md | 162 ++++++++++++++++++ packages/core/src/plugin/skill.ts | 12 +- .../src/hook/extensions/hot-reload.ts | 11 +- packages/opencode/src/skill/index.ts | 6 +- .../test/hook/settings-hot-reload.test.ts | 33 ++++ 5 files changed, 210 insertions(+), 14 deletions(-) create mode 100644 hooks-leftover-issues.md diff --git a/hooks-leftover-issues.md b/hooks-leftover-issues.md new file mode 100644 index 0000000000..ca87785713 --- /dev/null +++ b/hooks-leftover-issues.md @@ -0,0 +1,162 @@ +# Hooks 体系遗留问题(FABLE5 Review 发现) + +> 来源:`hooks-dynamic-context-and-create-command` change 的 FABLE5 code review +> 日期:2026-07-03 +> 状态:待决策 + +## 背景 + +`hooks-dynamic-context-and-create-command` change 实现完成后,FABLE5 模型做了一次 code review。Review 确认了核心实现正确,但标记了 3 个**前置提交遗留问题**(不属于该 change 的范围,来自 hooks 迁移系列 PR)。本文档汇总这 3 个问题供进一步研究。 + +--- + +## 问题 ①:内置 skill description 硬编码重复 + +### 现象 + +两个注册路径各硬编码了一份完全相同的长 description 字符串: + +| 文件 | 位置 | 注册路径 | +|------|------|---------| +| `packages/core/src/plugin/skill.ts:36-37` | `Plugin.define` 内联 | V2 plugin system(`ctx.skill.transform`) | +| `packages/opencode/src/skill/index.ts:43-44` | `CONFIGURE_HOOKS_SKILL_DESCRIPTION` 常量 | Legacy Skill layer(直接写入 `state.skills`) | + +涉及两个 skill:`configure-hooks` 和 `customize-opencode`,各有完全相同的重复。 + +### 细节 + +有趣的是 **skill body 已经通过 import 共享了**: + +```ts +// opencode/src/skill/index.ts +import { SkillPlugin } from "@opencode-ai/core/plugin/skill" +const CONFIGURE_HOOKS_SKILL_BODY = SkillPlugin.ConfigureHooksContent // ✅ 已共享 +const CONFIGURE_HOOKS_SKILL_DESCRIPTION = "Use when the user wants to..." // ❌ 硬编码副本 +``` + +只有 description 是各写一份。`core/src/plugin/skill.ts` 已经导出了 body(`ConfigureHooksContent` / `CustomizeOpencodeContent`),但没有导出 description 常量。 + +### 风险 + +改了一边忘另一边 → 两条注册路径注册的 description 不一致 → agent 对同一个 skill 看到不同的触发条件描述。 + +### 待研究问题 + +1. 这两条注册路径(V2 plugin vs legacy Skill layer)是什么关系?是同一套 skill 注册两次(后者覆盖前者),还是两个独立系统并存? +2. 如果是重复注册,更根本的修法是消除重复注册;如果独立并存,共享常量就够。 +3. 注册顺序:opencode 的 `state.skills[name] = ...` 在 `loadSkills()` 之前执行,注释写 "BEFORE disk discovery so a user-disk skill with the same name can override it"。V2 plugin 的注册时机是什么? + +### 初步修法方向 + +`core/src/plugin/skill.ts` 导出 description 常量,`opencode/src/skill/index.ts` import 使用。约 10 行改动,2 个文件。 + +--- + +## 问题 ②:hot-reload mtime 检测遗漏 `m < prev` 场景 + +### 现象 + +`packages/opencode/src/hook/extensions/hot-reload.ts:144`: + +```ts +if (m > prev || (prev > 0 && m === 0)) { +// ^^^^^^^^ ^^^^^^^^^^ +// 只检测 mtime 增大 检测删除(>0 → 0) +``` + +### 遗漏的场景 + +| 操作 | mtime 变化 | 当前检测 | 应该触发 reload? | +|------|-----------|---------|------------------| +| 正常编辑(vim / echo >) | `100 → 200` | ✅ `m > prev` | 是 | +| 删除文件 | `200 → 0` | ✅ `prev>0 && m===0` | 是 | +| `cp -p` 从备份恢复(保留时间戳) | `200 → 100` | ❌ 漏了 | 是 | +| `touch -t` 设置旧时间 | `200 → 50` | ❌ 漏了 | 是 | + +### 影响 + +极低频率。正常编辑工具(vim / echo / tee / sed -i)都增大 mtime。只有 `cp -p` 或 `touch -t` 显式保留/设置旧时间戳才会触发。但一旦触发,用户改了 hooks.json 却不生效,排查困难。 + +### 修法 + +`m > prev` → `m !== prev`。reload 是幂等的(重新 loadChain,结果相同就无副作用),多触发一次无代价。 + +```ts +// 修后 +if (m !== prev) { // 涵盖增大、减小、删除(>0→0 也是 !==) +``` + +注意:`prev > 0 && m === 0` 这条单独的删除检测可以合并进去(`m !== prev` 已覆盖),但保留也无害——只是冗余。 + +### 范围 + +1 行改动,1 个文件。太小不值得单独开 OpenSpec change。 + +--- + +## 问题 ③:系统提示词中 "Claude Code Hooks API" 段落描述过时的 hooks 体系 + +### 现象 + +当前 opencode session 的系统提示词中包含一个 `# Claude Code Hooks API` 段落,描述了**旧的 6 层 settings 链和 22 个事件**: + +``` +# Claude Code Hooks API + +OpenCode is **fully compatible with the Claude Code hooks protocol**... + +**Settings paths** (6-layer chain, merged in order): +1. `~/.claude/settings.json` (global) +2. `/.claude/settings.json` (project) +3. `/.claude/settings.local.json` (project-local) +4. `~/.config/opencode/settings.json` (global) +5. `/.opencode/settings.json` (project) +6. `/.opencode/settings.local.json` (project-local) + +**22 actively triggered events**: PreToolUse, PostToolUse, PostToolUseFailure, FileChanged, ... +``` + +而当前实现已经是 **3 层 hooks.json 链 + 27 个事件**。这段文字与实现矛盾。 + +### 调查结果 + +| 搜索范围 | 结果 | +|---------|------| +| repo 内所有 `.ts` / `.txt` / `.md` / `.js` 文件 | ❌ 找不到 | +| `~/.config/opencode/` 全局配置 | ❌ 找不到 | +| `~/.config/opencode/docs/hooks-reference.md`(文中引用的文件) | ❌ 文件不存在 | +| git 全历史 `-S` 搜索 | 超时未完成 | +| `/usr/local/bin/opencode`(全局二进制)`strings` 搜索 | 未直接命中(Bun 编译可能压缩了 JS bundle) | + +### 根因推断 + +当前 session 跑的是 `/usr/local/bin/opencode`(全局安装的**旧版编译二进制**),不是 `bun dev` 的源码。这段文字很可能在旧版源码中存在(hooks 迁移前),迁移时已从源码删除,但全局二进制还是旧的。 + +**一旦 hooks 迁移系列 PR 合入 main 并重新发版安装,此问题自动消失。** + +### 验证方式 + +hooks 迁移合入 main 后,重新编译安装全局二进制(或 `bun install -g`),启动新 session 检查系统提示词是否还有这段过时文字。 + +### 待研究问题 + +1. 这段文字在旧版源码中的确切位置是什么?(git log 搜索超时,可以缩小搜索范围重试) +2. 它是硬编码在某个 `.ts` 文件里,还是从某个 `.md` / `.txt` 模板加载的? +3. 确认它已经从当前源码中删除(而非仍然存在只是我们搜错了关键词)。 + +--- + +## 汇总决策矩阵 + +| 问题 | 源码改动? | 改动量 | 风险 | 建议 | +|------|-----------|--------|------|------| +| ① description 重复 | 是 | ~10 行 / 2 文件 | 极低 | 值得做:消除漂移风险 + 搞清注册路径关系 | +| ② m > prev 边界 | 是 | 1 行 | 极低 | 太小,搭便车修(放在 ① 的 change 里或下个 hooks PR) | +| ③ 系统提示词过时文字 | 否(部署问题) | 0 行 | 中(agent 拿到错误指引) | 重新发版安装即自动修复;可选:追查旧文字的确切来源以确认已删 | + +--- + +## 建议 + +- **① + ② 合并为一个 change**:都是 skill/hook 注册体系的小修,scope 内聚。① 是主菜,② 是搭便车的一行修。 +- **③ 不需要代码改动**:确认 hooks 迁移合入后重新安装即可。如果想在合入前彻底确认旧文字已从源码删除,可以缩窄 git log 搜索范围(限定 `--since` 日期或 `-- packages/` 路径)。 diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index c54febe7ea..5a8bc85760 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -12,6 +12,12 @@ import configureHooksContent from "./skill/configure-hooks.md" with { type: "tex export const CustomizeOpencodeContent = customizeOpencodeContent export const ConfigureHooksContent = configureHooksContent +export const CustomizeOpencodeDescription = + "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself." + +export const ConfigureHooksDescription = + "Use when the user wants to automatically run something on an opencode event — before/after a tool call, on session start/end, on compaction, etc. — or asks about opencode's hooks / hooks.json / event hooks. Covers hooks.json file locations and format, the 27 supported events, and the 5 hook types (command, mcp, http, prompt, agent). Also use to migrate hooks from Claude Code's .claude/settings.json via /import-claude-hooks." + export const Plugin = define({ id: "skill", effect: Effect.fn(function* (ctx) { @@ -21,8 +27,7 @@ export const Plugin = define({ type: "embedded", skill: SkillV2.Info.make({ name: "customize-opencode", - description: - "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself.", + description: CustomizeOpencodeDescription, location: AbsolutePath.make("/builtin/customize-opencode.md"), content: CustomizeOpencodeContent, }), @@ -33,8 +38,7 @@ export const Plugin = define({ type: "embedded", skill: SkillV2.Info.make({ name: "configure-hooks", - description: - "Use when the user wants to automatically run something on an opencode event — before/after a tool call, on session start/end, on compaction, etc. — or asks about opencode's hooks / hooks.json / event hooks. Covers hooks.json file locations and format, the 27 supported events, and the 5 hook types (command, mcp, http, prompt, agent). Also use to migrate hooks from Claude Code's .claude/settings.json via /import-claude-hooks.", + description: ConfigureHooksDescription, location: AbsolutePath.make("/builtin/configure-hooks.md"), content: ConfigureHooksContent, }), diff --git a/packages/opencode/src/hook/extensions/hot-reload.ts b/packages/opencode/src/hook/extensions/hot-reload.ts index c799e38704..8780966051 100644 --- a/packages/opencode/src/hook/extensions/hot-reload.ts +++ b/packages/opencode/src/hook/extensions/hot-reload.ts @@ -132,16 +132,15 @@ export function watchSettings( const check = () => { if (closed) return - // Detect both modification (mtime increased) and deletion (mtime went from - // non-zero to 0). Either triggers a reload since deleting hooks.json should - // unload those hooks. Update mtime snapshots and schedule a single reload - // — reload() re-reads the whole chain (loadChain handles missing files). + // Detect any mtime change (increase, decrease, or deletion → 0) and trigger + // a reload. reload() re-reads the whole chain (loadChain handles missing + // files) and is idempotent, so treating any change uniformly is safe — + // including cp -p / touch -t restoring an older timestamp. let changedFile: string | undefined for (const f of files) { const m = mtimeOrZero(f) const prev = mtimes.get(f) ?? 0 - // Detect: file modified (mtime increased) OR file deleted (mtime went from >0 to 0) - if (m > prev || (prev > 0 && m === 0)) { + if (m !== prev) { mtimes.set(f, m) changedFile = f } diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 50ead1323e..64aba839eb 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -30,8 +30,7 @@ const SKILL_PATTERN = "**/SKILL.md" // when the model is asked to touch opencode's own config files gives it the // actual schemas instead of guesses. const CUSTOMIZE_OPENCODE_SKILL_NAME = "customize-opencode" -const CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION = - "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself." +const CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION = SkillPlugin.CustomizeOpencodeDescription const CUSTOMIZE_OPENCODE_SKILL_BODY = SkillPlugin.CustomizeOpencodeContent // Built-in skill. Agents have no innate knowledge of opencode's hooks system @@ -40,8 +39,7 @@ const CUSTOMIZE_OPENCODE_SKILL_BODY = SkillPlugin.CustomizeOpencodeContent // know hooks are possible and when to reach for them; the body (loaded lazily // on skill invocation) has the event list, file format, and handler protocol. const CONFIGURE_HOOKS_SKILL_NAME = "configure-hooks" -const CONFIGURE_HOOKS_SKILL_DESCRIPTION = - "Use when the user wants to automatically run something on an opencode event — before/after a tool call, on session start/end, on compaction, etc. — or asks about opencode's hooks / hooks.json / event hooks. Covers hooks.json file locations and format, the 27 supported events, and the 5 hook types (command, mcp, http, prompt, agent). Also use to migrate hooks from Claude Code's .claude/settings.json via /import-claude-hooks." +const CONFIGURE_HOOKS_SKILL_DESCRIPTION = SkillPlugin.ConfigureHooksDescription const CONFIGURE_HOOKS_SKILL_BODY = SkillPlugin.ConfigureHooksContent export const Info = Schema.Struct({ diff --git a/packages/opencode/test/hook/settings-hot-reload.test.ts b/packages/opencode/test/hook/settings-hot-reload.test.ts index 02671dcea8..136f7ffbb2 100644 --- a/packages/opencode/test/hook/settings-hot-reload.test.ts +++ b/packages/opencode/test/hook/settings-hot-reload.test.ts @@ -140,4 +140,37 @@ describe("SettingsHook hot-reload — watchSettings wiring (F3)", () => { await fs.rm(dir, { recursive: true, force: true }) }, 15000) + + test("watchSettings reloads when mtime decreases (cp -p / touch -t)", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "hot-reload-mtime-dec-")) + const file = settingsPath(dir) + await fs.mkdir(opencodeDir(dir), { recursive: true }) + await fs.writeFile(file, JSON.stringify({})) + + let marker: string | undefined + const handle = watchSettings( + dir, + undefined, + () => Effect.sync(() => ({ hooks: {} })), + () => { + marker = "reloaded" + }, + ) + + // Wait for the construction-time mtime snapshot to be captured. + await sleep(50) + + // Overwrite the file, then rewind its mtime to 60s ago — simulates + // `cp -p` from a backup or `touch -t`. The mtime is now LOWER than the + // snapshot the watcher took at construction. + await fs.writeFile(file, JSON.stringify({ Stop: [] })) + const oldTime = new Date(Date.now() - 60_000) + await fs.utimes(file, oldTime, oldTime) + + await sleep(3000) + expect(marker).toBe("reloaded") + + handle.close() + await fs.rm(dir, { recursive: true, force: true }) + }, 15000) }) From 3ae60026a56bc6b8e447fe0b885c97034e8bc8a3 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 3 Jul 2026 21:47:29 +0800 Subject: [PATCH 3/5] fix(core): guard projector against orphaned part updates after cleanup PartUpdated events from interrupted streams can arrive after revert cleanup has deleted the parent message. The projector now silently skips parts whose parent message no longer exists instead of crashing with FOREIGN KEY constraint failure. Includes regression test. --- packages/core/src/session/projector.ts | 11 +++++ packages/core/test/session-projector.test.ts | 42 +++++++++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 326b1bbddb..261aed4ad9 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -316,6 +316,17 @@ export const layer = Layer.effectDiscard( const messageID = event.data.part.messageID const sessionID = event.data.part.sessionID const data = partData(event.data.part) + // A part update can race with revert cleanup deleting its parent message + // (e.g. an interrupted stream flushing a step-start after resend). The + // message is gone, so the part is moot — skip instead of dying on the + // foreign key constraint. + const message = yield* db + .select({ id: MessageTable.id }) + .from(MessageTable) + .where(eq(MessageTable.id, messageID)) + .get() + .pipe(Effect.orDie) + if (!message) return const row = yield* db.select().from(PartTable).where(eq(PartTable.id, id)).get().pipe(Effect.orDie) yield* db .insert(PartTable) diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index 3b568cb954..266f899868 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -10,6 +10,8 @@ import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" +import { SessionV1 } from "@opencode-ai/core/v1/session" +import { SessionID } from "@opencode-ai/schema/session-id" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionMessage } from "@opencode-ai/core/session/message" @@ -19,7 +21,7 @@ import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionInput } from "@opencode-ai/core/session/input" import { SessionStore } from "@opencode-ai/core/session/store" -import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" +import { PartTable, SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" import { testEffect } from "./lib/effect" import { Snapshot } from "@opencode-ai/core/snapshot" @@ -43,6 +45,44 @@ const assistantRow = ( } describe("SessionProjector", () => { + it.effect("skips part updates whose parent message no longer exists", () => + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "test", + directory: "/project", + title: "test", + version: "test", + }) + .run() + .pipe(Effect.orDie) + const events = yield* EventV2.Service + // A revert cleanup can delete a message while an interrupted stream still + // flushes a part update for it — projection must ignore the orphan part + // instead of dying on the foreign key constraint. + yield* events.publish(SessionV1.Event.PartUpdated, { + sessionID: SessionID.make(sessionID), + time: 1, + part: { + type: "step-start", + id: SessionV1.PartID.make("prt_orphan"), + messageID: SessionV1.MessageID.make("msg_missing"), + sessionID: SessionID.make(sessionID), + }, + }) + expect(yield* db.select().from(PartTable).all().pipe(Effect.orDie)).toEqual([]) + }), + ) + it.effect("projects staged, cleared, and committed reverts", () => Effect.gen(function* () { const db = (yield* Database.Service).db From d9c755934591b8b33dfbae752617c20245af6846 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 3 Jul 2026 22:19:25 +0800 Subject: [PATCH 4/5] chore: remove working notes file (hooks-leftover-issues.md) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Temporary analysis document from review process; content is now stale (issues ①② fixed in this PR) or tracked separately (issue ③). --- hooks-leftover-issues.md | 162 --------------------------------------- 1 file changed, 162 deletions(-) delete mode 100644 hooks-leftover-issues.md diff --git a/hooks-leftover-issues.md b/hooks-leftover-issues.md deleted file mode 100644 index ca87785713..0000000000 --- a/hooks-leftover-issues.md +++ /dev/null @@ -1,162 +0,0 @@ -# Hooks 体系遗留问题(FABLE5 Review 发现) - -> 来源:`hooks-dynamic-context-and-create-command` change 的 FABLE5 code review -> 日期:2026-07-03 -> 状态:待决策 - -## 背景 - -`hooks-dynamic-context-and-create-command` change 实现完成后,FABLE5 模型做了一次 code review。Review 确认了核心实现正确,但标记了 3 个**前置提交遗留问题**(不属于该 change 的范围,来自 hooks 迁移系列 PR)。本文档汇总这 3 个问题供进一步研究。 - ---- - -## 问题 ①:内置 skill description 硬编码重复 - -### 现象 - -两个注册路径各硬编码了一份完全相同的长 description 字符串: - -| 文件 | 位置 | 注册路径 | -|------|------|---------| -| `packages/core/src/plugin/skill.ts:36-37` | `Plugin.define` 内联 | V2 plugin system(`ctx.skill.transform`) | -| `packages/opencode/src/skill/index.ts:43-44` | `CONFIGURE_HOOKS_SKILL_DESCRIPTION` 常量 | Legacy Skill layer(直接写入 `state.skills`) | - -涉及两个 skill:`configure-hooks` 和 `customize-opencode`,各有完全相同的重复。 - -### 细节 - -有趣的是 **skill body 已经通过 import 共享了**: - -```ts -// opencode/src/skill/index.ts -import { SkillPlugin } from "@opencode-ai/core/plugin/skill" -const CONFIGURE_HOOKS_SKILL_BODY = SkillPlugin.ConfigureHooksContent // ✅ 已共享 -const CONFIGURE_HOOKS_SKILL_DESCRIPTION = "Use when the user wants to..." // ❌ 硬编码副本 -``` - -只有 description 是各写一份。`core/src/plugin/skill.ts` 已经导出了 body(`ConfigureHooksContent` / `CustomizeOpencodeContent`),但没有导出 description 常量。 - -### 风险 - -改了一边忘另一边 → 两条注册路径注册的 description 不一致 → agent 对同一个 skill 看到不同的触发条件描述。 - -### 待研究问题 - -1. 这两条注册路径(V2 plugin vs legacy Skill layer)是什么关系?是同一套 skill 注册两次(后者覆盖前者),还是两个独立系统并存? -2. 如果是重复注册,更根本的修法是消除重复注册;如果独立并存,共享常量就够。 -3. 注册顺序:opencode 的 `state.skills[name] = ...` 在 `loadSkills()` 之前执行,注释写 "BEFORE disk discovery so a user-disk skill with the same name can override it"。V2 plugin 的注册时机是什么? - -### 初步修法方向 - -`core/src/plugin/skill.ts` 导出 description 常量,`opencode/src/skill/index.ts` import 使用。约 10 行改动,2 个文件。 - ---- - -## 问题 ②:hot-reload mtime 检测遗漏 `m < prev` 场景 - -### 现象 - -`packages/opencode/src/hook/extensions/hot-reload.ts:144`: - -```ts -if (m > prev || (prev > 0 && m === 0)) { -// ^^^^^^^^ ^^^^^^^^^^ -// 只检测 mtime 增大 检测删除(>0 → 0) -``` - -### 遗漏的场景 - -| 操作 | mtime 变化 | 当前检测 | 应该触发 reload? | -|------|-----------|---------|------------------| -| 正常编辑(vim / echo >) | `100 → 200` | ✅ `m > prev` | 是 | -| 删除文件 | `200 → 0` | ✅ `prev>0 && m===0` | 是 | -| `cp -p` 从备份恢复(保留时间戳) | `200 → 100` | ❌ 漏了 | 是 | -| `touch -t` 设置旧时间 | `200 → 50` | ❌ 漏了 | 是 | - -### 影响 - -极低频率。正常编辑工具(vim / echo / tee / sed -i)都增大 mtime。只有 `cp -p` 或 `touch -t` 显式保留/设置旧时间戳才会触发。但一旦触发,用户改了 hooks.json 却不生效,排查困难。 - -### 修法 - -`m > prev` → `m !== prev`。reload 是幂等的(重新 loadChain,结果相同就无副作用),多触发一次无代价。 - -```ts -// 修后 -if (m !== prev) { // 涵盖增大、减小、删除(>0→0 也是 !==) -``` - -注意:`prev > 0 && m === 0` 这条单独的删除检测可以合并进去(`m !== prev` 已覆盖),但保留也无害——只是冗余。 - -### 范围 - -1 行改动,1 个文件。太小不值得单独开 OpenSpec change。 - ---- - -## 问题 ③:系统提示词中 "Claude Code Hooks API" 段落描述过时的 hooks 体系 - -### 现象 - -当前 opencode session 的系统提示词中包含一个 `# Claude Code Hooks API` 段落,描述了**旧的 6 层 settings 链和 22 个事件**: - -``` -# Claude Code Hooks API - -OpenCode is **fully compatible with the Claude Code hooks protocol**... - -**Settings paths** (6-layer chain, merged in order): -1. `~/.claude/settings.json` (global) -2. `/.claude/settings.json` (project) -3. `/.claude/settings.local.json` (project-local) -4. `~/.config/opencode/settings.json` (global) -5. `/.opencode/settings.json` (project) -6. `/.opencode/settings.local.json` (project-local) - -**22 actively triggered events**: PreToolUse, PostToolUse, PostToolUseFailure, FileChanged, ... -``` - -而当前实现已经是 **3 层 hooks.json 链 + 27 个事件**。这段文字与实现矛盾。 - -### 调查结果 - -| 搜索范围 | 结果 | -|---------|------| -| repo 内所有 `.ts` / `.txt` / `.md` / `.js` 文件 | ❌ 找不到 | -| `~/.config/opencode/` 全局配置 | ❌ 找不到 | -| `~/.config/opencode/docs/hooks-reference.md`(文中引用的文件) | ❌ 文件不存在 | -| git 全历史 `-S` 搜索 | 超时未完成 | -| `/usr/local/bin/opencode`(全局二进制)`strings` 搜索 | 未直接命中(Bun 编译可能压缩了 JS bundle) | - -### 根因推断 - -当前 session 跑的是 `/usr/local/bin/opencode`(全局安装的**旧版编译二进制**),不是 `bun dev` 的源码。这段文字很可能在旧版源码中存在(hooks 迁移前),迁移时已从源码删除,但全局二进制还是旧的。 - -**一旦 hooks 迁移系列 PR 合入 main 并重新发版安装,此问题自动消失。** - -### 验证方式 - -hooks 迁移合入 main 后,重新编译安装全局二进制(或 `bun install -g`),启动新 session 检查系统提示词是否还有这段过时文字。 - -### 待研究问题 - -1. 这段文字在旧版源码中的确切位置是什么?(git log 搜索超时,可以缩小搜索范围重试) -2. 它是硬编码在某个 `.ts` 文件里,还是从某个 `.md` / `.txt` 模板加载的? -3. 确认它已经从当前源码中删除(而非仍然存在只是我们搜错了关键词)。 - ---- - -## 汇总决策矩阵 - -| 问题 | 源码改动? | 改动量 | 风险 | 建议 | -|------|-----------|--------|------|------| -| ① description 重复 | 是 | ~10 行 / 2 文件 | 极低 | 值得做:消除漂移风险 + 搞清注册路径关系 | -| ② m > prev 边界 | 是 | 1 行 | 极低 | 太小,搭便车修(放在 ① 的 change 里或下个 hooks PR) | -| ③ 系统提示词过时文字 | 否(部署问题) | 0 行 | 中(agent 拿到错误指引) | 重新发版安装即自动修复;可选:追查旧文字的确切来源以确认已删 | - ---- - -## 建议 - -- **① + ② 合并为一个 change**:都是 skill/hook 注册体系的小修,scope 内聚。① 是主菜,② 是搭便车的一行修。 -- **③ 不需要代码改动**:确认 hooks 迁移合入后重新安装即可。如果想在合入前彻底确认旧文字已从源码删除,可以缩窄 git log 搜索范围(限定 `--since` 日期或 `-- packages/` 路径)。 From 86e4bdb4578964a76937c7c14b192343041c592a Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 3 Jul 2026 22:38:34 +0800 Subject: [PATCH 5/5] refactor(hooks): address FABLE5 PR #57 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract chainCandidates() shared by loadChain + summarizeChain (eliminate duplicated path logic and Global.Path.config fallback) - Projector: add Effect.logWarning when skipping orphaned PartUpdated (was silent — genuine ordering bugs now observable in logs) - descriptorFor: unify .slice(0,60) across all types (http/prompt/agent/mcp were not truncated) - Integration test: real SettingsHook layer reaches sys.hooks() via serviceOption (not Layer.mock — proves the full chain works end-to-end) --- packages/core/src/session/projector.ts | 11 ++- packages/opencode/src/hook/settings.ts | 95 +++++++++---------- packages/opencode/test/hook/list.test.ts | 28 ++++++ packages/opencode/test/session/system.test.ts | 62 ++++++++++++ 4 files changed, 146 insertions(+), 50 deletions(-) diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 261aed4ad9..6605139903 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -319,14 +319,21 @@ export const layer = Layer.effectDiscard( // A part update can race with revert cleanup deleting its parent message // (e.g. an interrupted stream flushing a step-start after resend). The // message is gone, so the part is moot — skip instead of dying on the - // foreign key constraint. + // foreign key constraint. Warn so genuine ordering bugs remain observable. const message = yield* db .select({ id: MessageTable.id }) .from(MessageTable) .where(eq(MessageTable.id, messageID)) .get() .pipe(Effect.orDie) - if (!message) return + if (!message) { + yield* Effect.logWarning("part update skipped: parent message no longer exists", { + messageID, + partType: event.data.part.type, + sessionID, + }) + return + } const row = yield* db.select().from(PartTable).where(eq(PartTable.id, id)).get().pipe(Effect.orDie) yield* db .insert(PartTable) diff --git a/packages/opencode/src/hook/settings.ts b/packages/opencode/src/hook/settings.ts index 79c31fbb2a..94fc0d5905 100644 --- a/packages/opencode/src/hook/settings.ts +++ b/packages/opencode/src/hook/settings.ts @@ -555,20 +555,20 @@ function promptText(entry: HookCommand): string { /** * Short human-readable description of a hook entry for the Active Hooks block. - * command → first ~60 chars of the command; http → the URL; mcp → the tool - * name; prompt/agent → the first line of the prompt/goal text. + * All types are uniformly truncated to 60 chars: command → command text, + * http → URL, mcp → tool name, prompt/agent → first line of the prompt/goal. */ function descriptorFor(entry: HookCommand): string { switch (entry.type) { case "command": return commandText(entry).slice(0, 60) case "http": - return httpUrl(entry) + return httpUrl(entry).slice(0, 60) case "mcp": - return commandText(entry) + return commandText(entry).slice(0, 60) case "prompt": case "agent": - return promptText(entry).split("\n")[0] + return promptText(entry).split("\n")[0].slice(0, 60) default: return commandText(entry).slice(0, 60) } @@ -675,6 +675,42 @@ export function mergeSettings(layers: Settings[]): Settings { return out } +/** + * Resolve the OpenCode global config directory. Uses the explicit override + * when provided (tests), otherwise falls back to `Global.Path.config` with a + * `~/.config/opencode` default. Shared by chainCandidates and loadChain so + * the fallback logic exists in exactly one place. + */ +function resolveGlobalConfig(globalConfig?: string): string { + if (globalConfig) return globalConfig + try { + return Global.Path.config + } catch { + return path.join(os.homedir(), ".config", "opencode") + } +} + +/** + * Build the hooks.json candidate file list with scope tags. Shared by + * loadChain (merge) and summarizeChain (scope-tagged summaries) so adding or + * removing a path layer updates both consumers without a second edit. + */ +function chainCandidates( + directory: string, + worktree: string, + globalConfig?: string, +): Array<{ scope: "global" | "project" | "worktree"; file: string }> { + const opencodeGlobal = resolveGlobalConfig(globalConfig) + const candidates: Array<{ scope: "global" | "project" | "worktree"; file: string }> = [ + { scope: "global", file: path.join(opencodeGlobal, "hooks.json") }, + { scope: "project", file: path.join(directory, ".opencode", "hooks.json") }, + ] + if (worktree && worktree !== directory) { + candidates.push({ scope: "worktree", file: path.join(worktree, ".opencode", "hooks.json") }) + } + return candidates +} + /** * Produce scope-tagged summaries of the merged hooks chain — one entry per * individual hook command, tagged with the layer (global/project/worktree) it @@ -685,22 +721,7 @@ export function mergeSettings(layers: Settings[]): Settings { * Exported for unit testing only; not part of the public surface. */ export function summarizeChain(directory: string, worktree: string, globalConfig?: string): HookSummary[] { - const home = os.homedir() - const opencodeGlobal = globalConfig ?? (() => { - try { - return Global.Path.config - } catch { - return path.join(home, ".config", "opencode") - } - })() - const candidates: Array<{ scope: "global" | "project" | "worktree"; file: string }> = [ - { scope: "global", file: path.join(opencodeGlobal, "hooks.json") }, - { scope: "project", file: path.join(directory, ".opencode", "hooks.json") }, - ] - if (worktree && worktree !== directory) { - candidates.push({ scope: "worktree", file: path.join(worktree, ".opencode", "hooks.json") }) - } - return candidates.flatMap(({ scope, file }) => { + return chainCandidates(directory, worktree, globalConfig).flatMap(({ scope, file }) => { const data = readJSON(file) if (!data?.hooks) return [] return Object.entries(data.hooks).flatMap(([event, matchers]) => @@ -721,34 +742,12 @@ export function summarizeChain(directory: string, worktree: string, globalConfig // `globalConfig` overrides the resolved OpenCode global config dir so tests can // point it at an isolated temp dir instead of the real ~/.config/opencode. export function loadChain(directory: string, worktree: string, globalConfig?: string): Settings { - const home = os.homedir() - // Best-effort OpenCode global path; falls back to ~/.config/opencode. - // Optional globalConfig override is used by tests for deterministic isolation. - const opencodeGlobal = globalConfig ?? (() => { - try { - return Global.Path.config - } catch { - return path.join(home, ".config", "opencode") - } - })() - - // Hooks live in dedicated hooks.json files in OpenCode-owned directories only. - // `.claude/` is not read for hooks (complete cut); `.local` variants are dropped - // (one file per scope). Merge is concat-append (global → project → worktree). - const candidates = [ - path.join(opencodeGlobal, "hooks.json"), - path.join(directory, ".opencode", "hooks.json"), - ] - - // If worktree differs from directory (e.g. git worktree), also check it. - if (worktree && worktree !== directory) { - candidates.push(path.join(worktree, ".opencode", "hooks.json")) - } + const opencodeGlobal = resolveGlobalConfig(globalConfig) - const layers = candidates - .map((fp) => { - const data = readJSON(fp) - if (data) warnUnsupportedFields(data.hooks, path.dirname(fp)) + const layers = chainCandidates(directory, worktree, globalConfig) + .map(({ file }) => { + const data = readJSON(file) + if (data) warnUnsupportedFields(data.hooks, path.dirname(file)) return data }) .filter((s): s is Settings => s !== null) diff --git a/packages/opencode/test/hook/list.test.ts b/packages/opencode/test/hook/list.test.ts index 5a246cc3af..cb89cdfa5f 100644 --- a/packages/opencode/test/hook/list.test.ts +++ b/packages/opencode/test/hook/list.test.ts @@ -115,6 +115,20 @@ describe("summarizeChain — descriptor derivation by type", () => { } }) + test("http descriptor truncates long urls to 60 chars", async () => { + const longUrl = `https://example.com/${"x".repeat(80)}` + const projectDir = await mktmp("desc-http-long") + try { + await writeHooksJson(projectDir, { + PostToolUse: [{ hooks: [{ type: "http", url: longUrl }] }], + }) + const [summary] = summarizeChain(projectDir, "", projectDir) + expect(summary.descriptor.length).toBe(60) + } finally { + await fs.rm(projectDir, { recursive: true, force: true }) + } + }) + test("mcp descriptor is the tool name (command field)", async () => { const projectDir = await mktmp("desc-mcp") try { @@ -141,6 +155,20 @@ describe("summarizeChain — descriptor derivation by type", () => { } }) + test("prompt descriptor truncates long first lines to 60 chars", async () => { + const longPrompt = `${"a".repeat(80)}\nsecond line` + const projectDir = await mktmp("desc-prompt-long") + try { + await writeHooksJson(projectDir, { + Stop: [{ hooks: [{ type: "prompt", prompt: longPrompt }] }], + }) + const [summary] = summarizeChain(projectDir, "", projectDir) + expect(summary.descriptor.length).toBe(60) + } finally { + await fs.rm(projectDir, { recursive: true, force: true }) + } + }) + test("agent descriptor is the first line of the goal", async () => { const projectDir = await mktmp("desc-agent") try { diff --git a/packages/opencode/test/session/system.test.ts b/packages/opencode/test/session/system.test.ts index 1d2c8312e0..66bf6f88f7 100644 --- a/packages/opencode/test/session/system.test.ts +++ b/packages/opencode/test/session/system.test.ts @@ -1,5 +1,7 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" +import * as fs from "fs/promises" +import path from "path" import type { Agent } from "../../src/agent/agent" import { NamedError } from "@opencode-ai/core/util/error" import { Skill } from "../../src/skill" @@ -7,6 +9,9 @@ import { Permission } from "../../src/permission" import { SystemPrompt } from "../../src/session/system" import { MCP } from "../../src/mcp" import { SettingsHook, type HookSummary } from "../../src/hook/settings" +import { SessionHooks } from "../../src/hook/session-hooks" +import { EventV2Bridge } from "../../src/event-v2-bridge" +import { Database } from "@opencode-ai/core/database/database" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { testEffect } from "../lib/effect" @@ -209,3 +214,60 @@ describe("session.system", () => { }), ) }) + +// Integration layer: real SettingsHook (not mocked) + real SystemPrompt. +// Proves the full chain: InstanceState → loadChain → summarizeChain → list() +// → serviceOption resolution in sys.hooks() — the "silent no-op" failure mode +// AGENTS.md warns about when a .node dep is missing. +const integrationLayer = Layer.mergeAll( + SystemPrompt.layer.pipe( + Layer.provide(LocationServiceMap.layer), + Layer.provide(Layer.mock(MCP.Service, { instructions: () => Effect.succeed([]) })), + Layer.provide( + Layer.succeed( + Skill.Service, + Skill.Service.of({ + get: () => Effect.succeed(undefined), + require: (name) => Effect.fail(new Skill.NotFoundError({ name, available: [] })), + all: () => Effect.succeed(skills), + dirs: () => Effect.succeed([]), + available: () => Effect.succeed(skills), + }), + ), + ), + ), + SettingsHook.layer.pipe( + Layer.provide(EventV2Bridge.defaultLayer), + Layer.provide(Database.defaultLayer), + Layer.provideMerge(SessionHooks.defaultLayer), + ), +) + +const itIntegration = testEffect(integrationLayer) + +describe("session.system integration (real SettingsHook)", () => { + itIntegration.instance( + "hooks block renders with real SettingsHook layer via serviceOption", + () => + Effect.gen(function* () { + const prompt = yield* SystemPrompt.Service + const output = yield* prompt.hooks() + expect(output.length).toBe(1) + expect(output[0]).toContain("## Active Hooks") + expect(output[0]).toContain("echo integration-test") + }), + { + init: (dir) => + Effect.promise(async () => { + const opencode = path.join(dir, ".opencode") + await fs.mkdir(opencode, { recursive: true }) + await fs.writeFile( + path.join(opencode, "hooks.json"), + JSON.stringify({ + Stop: [{ hooks: [{ type: "command", command: "echo integration-test" }] }], + }), + ) + }), + }, + ) +})