diff --git a/AGENTS.md b/AGENTS.md index a5b8d144..40a72f6a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,8 @@ # PROJECT KNOWLEDGE BASE -**Generated:** 2026-02-10T14:44:00+09:00 -**Commit:** b538806d -**Branch:** dev +**Generated:** 2026-02-16T14:58:00+09:00 +**Commit:** 28cd34c3 +**Branch:** fuck-v1.2 --- @@ -102,32 +102,32 @@ Oh-My-OpenCode is a **plugin for OpenCode**. You will frequently need to examine ## OVERVIEW -OpenCode plugin (v3.4.0): multi-model agent orchestration with 11 specialized agents (Claude Opus 4.6, GPT-5.3 Codex, Gemini 3 Flash, GLM-4.7, Grok). 41 lifecycle hooks across 7 event types, 25+ tools (LSP, AST-Grep, delegation, task management), full Claude Code compatibility layer. "oh-my-zsh" for OpenCode. +OpenCode plugin (oh-my-opencode): multi-model agent orchestration with 11 specialized agents, 41 lifecycle hooks across 7 event types, 26 tools (LSP, AST-Grep, delegation, task management), full Claude Code compatibility layer, 4-scope skill loading, background agent concurrency, tmux integration, and 3-tier MCP system. "oh-my-zsh" for OpenCode. ## STRUCTURE ``` oh-my-opencode/ ├── src/ -│ ├── agents/ # 11 AI agents - see src/agents/AGENTS.md -│ ├── hooks/ # 41 lifecycle hooks - see src/hooks/AGENTS.md -│ ├── tools/ # 25+ tools - see src/tools/AGENTS.md -│ ├── features/ # Background agents, skills, CC compat - see src/features/AGENTS.md -│ ├── shared/ # 84 cross-cutting utilities - see src/shared/AGENTS.md -│ ├── cli/ # CLI installer, doctor - see src/cli/AGENTS.md -│ ├── mcp/ # Built-in MCPs - see src/mcp/AGENTS.md -│ ├── config/ # Zod schema - see src/config/AGENTS.md -│ ├── plugin-handlers/ # Config loading - see src/plugin-handlers/AGENTS.md +│ ├── agents/ # 11 AI agents — see src/agents/AGENTS.md +│ ├── hooks/ # 41 lifecycle hooks — see src/hooks/AGENTS.md +│ ├── tools/ # 26 tools — see src/tools/AGENTS.md +│ ├── features/ # Background agents, skills, CC compat — see src/features/AGENTS.md +│ ├── shared/ # Cross-cutting utilities — see src/shared/AGENTS.md +│ ├── cli/ # CLI installer, doctor — see src/cli/AGENTS.md +│ ├── mcp/ # Built-in MCPs — see src/mcp/AGENTS.md +│ ├── config/ # Zod schema — see src/config/AGENTS.md +│ ├── plugin-handlers/ # Config loading pipeline — see src/plugin-handlers/AGENTS.md │ ├── plugin/ # Plugin interface composition (21 files) -│ ├── index.ts # Main plugin entry (88 lines) +│ ├── index.ts # Main plugin entry (106 lines) │ ├── create-hooks.ts # Hook creation coordination (62 lines) │ ├── create-managers.ts # Manager initialization (80 lines) │ ├── create-tools.ts # Tool registry composition (54 lines) │ ├── plugin-interface.ts # Plugin interface assembly (66 lines) -│ ├── plugin-config.ts # Config loading orchestration -│ └── plugin-state.ts # Model cache state +│ ├── plugin-config.ts # Config loading orchestration (180 lines) +│ └── plugin-state.ts # Model cache state (12 lines) ├── script/ # build-schema.ts, build-binaries.ts, publish.ts, generate-changelog.ts -├── packages/ # 7 platform-specific binary packages +├── packages/ # 11 platform-specific binary packages └── dist/ # Build output (ESM + .d.ts) ``` @@ -143,7 +143,7 @@ OhMyOpenCodePlugin(ctx) 6. createManagers(ctx, config, tmux, cache) → TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler 7. createTools(ctx, config, managers) → filteredTools, mergedSkills, availableSkills, availableCategories 8. createHooks(ctx, config, backgroundMgr) → 41 hooks (core + continuation + skill) - 9. createPluginInterface(...) → tool, chat.params, chat.message, event, tool.execute.before/after + 9. createPluginInterface(...) → 7 OpenCode hook handlers 10. Return plugin with experimental.session.compacting ``` @@ -159,7 +159,7 @@ OhMyOpenCodePlugin(ctx) | Add command | `src/features/builtin-commands/` | Add template + register in commands.ts | | Config schema | `src/config/schema/` | 21 schema component files, run `bun run build:schema` | | Plugin config | `src/plugin-handlers/config-handler.ts` | JSONC loading, merging, migration | -| Background agents | `src/features/background-agent/` | manager.ts (1646 lines) | +| Background agents | `src/features/background-agent/` | manager.ts (1701 lines) | | Orchestrator | `src/hooks/atlas/` | Main orchestration hook (1976 lines) | | Delegation | `src/tools/delegate-task/` | Category routing (constants.ts 569 lines) | | Task system | `src/features/claude-tasks/` | Task schema, storage, todo sync | @@ -174,7 +174,7 @@ OhMyOpenCodePlugin(ctx) **Rules:** - NEVER write implementation before test -- NEVER delete failing tests - fix the code +- NEVER delete failing tests — fix the code - Test file: `*.test.ts` alongside source (176 test files) - BDD comments: `//#given`, `//#when`, `//#then` @@ -185,7 +185,7 @@ OhMyOpenCodePlugin(ctx) - **Build**: `bun build` (ESM) + `tsc --emitDeclarationOnly` - **Exports**: Barrel pattern via index.ts - **Naming**: kebab-case dirs, `createXXXHook`/`createXXXTool` factories -- **Testing**: BDD comments, 176 test files, 117k+ lines TypeScript +- **Testing**: BDD comments, 176 test files, 1130 TypeScript files - **Temperature**: 0.1 for code agents, max 0.3 - **Modular architecture**: 200 LOC hard limit per file (prompt strings exempt) @@ -193,24 +193,24 @@ OhMyOpenCodePlugin(ctx) | Category | Forbidden | |----------|-----------| -| Package Manager | npm, yarn - Bun exclusively | -| Types | @types/node - use bun-types | -| File Ops | mkdir/touch/rm/cp/mv in code - use bash tool | -| Publishing | Direct `bun publish` - GitHub Actions only | -| Versioning | Local version bump - CI manages | +| Package Manager | npm, yarn — Bun exclusively | +| Types | @types/node — use bun-types | +| File Ops | mkdir/touch/rm/cp/mv in code — use bash tool | +| Publishing | Direct `bun publish` — GitHub Actions only | +| Versioning | Local version bump — CI manages | | Type Safety | `as any`, `@ts-ignore`, `@ts-expect-error` | | Error Handling | Empty catch blocks | | Testing | Deleting failing tests, writing implementation before test | -| Agent Calls | Sequential - use `task` parallel | -| Hook Logic | Heavy PreToolUse - slows every call | +| Agent Calls | Sequential — use `task` parallel | +| Hook Logic | Heavy PreToolUse — slows every call | | Commits | Giant (3+ files), separate test from impl | | Temperature | >0.3 for code agents | -| Trust | Agent self-reports - ALWAYS verify | +| Trust | Agent self-reports — ALWAYS verify | | Git | `git add -i`, `git rebase -i` (no interactive input) | | Git | Skip hooks (--no-verify), force push without request | -| Bash | `sleep N` - use conditional waits | -| Bash | `cd dir && cmd` - use workdir parameter | -| Files | Catch-all utils.ts/helpers.ts - name by purpose | +| Bash | `sleep N` — use conditional waits | +| Bash | `cd dir && cmd` — use workdir parameter | +| Files | Catch-all utils.ts/helpers.ts — name by purpose | ## AGENT MODELS @@ -230,7 +230,7 @@ OhMyOpenCodePlugin(ctx) ## OPENCODE PLUGIN API -Plugin SDK from `@opencode-ai/plugin` (v1.1.19). Plugin = `async (PluginInput) => Hooks`. +Plugin SDK from `@opencode-ai/plugin`. Plugin = `async (PluginInput) => Hooks`. | Hook | Purpose | |------|---------| @@ -283,7 +283,7 @@ bun run build:schema # Regenerate JSON schema | File | Lines | Description | |------|-------|-------------| -| `src/features/background-agent/manager.ts` | 1646 | Task lifecycle, concurrency | +| `src/features/background-agent/manager.ts` | 1701 | Task lifecycle, concurrency | | `src/hooks/anthropic-context-window-limit-recovery/` | 2232 | Multi-strategy context recovery | | `src/hooks/claude-code-hooks/` | 2110 | Claude Code settings.json compat | | `src/hooks/todo-continuation-enforcer/` | 2061 | Core boulder mechanism | @@ -293,7 +293,7 @@ bun run build:schema # Regenerate JSON schema | `src/hooks/rules-injector/` | 1604 | Conditional rules injection | | `src/hooks/think-mode/` | 1365 | Model/variant switching | | `src/hooks/session-recovery/` | 1279 | Auto error recovery | -| `src/features/builtin-skills/skills/git-master.ts` | 1111 | Git master skill | +| `src/features/builtin-skills/skills/git-master.ts` | 1112 | Git master skill | | `src/tools/delegate-task/constants.ts` | 569 | Category routing configs | ## MCP ARCHITECTURE @@ -313,7 +313,7 @@ Three-tier system: ## NOTES - **OpenCode**: Requires >= 1.0.150 -- **1069 TypeScript files**, 176 test files, 117k+ lines +- **1130 TypeScript files**, 176 test files, 127k+ lines - **Flaky tests**: ralph-loop (CI timeout), session-state (parallel pollution) - **Trusted deps**: @ast-grep/cli, @ast-grep/napi, @code-yeongyu/comment-checker - **No linter/formatter**: No ESLint, Prettier, or Biome configured diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index c443ff7e..32753645 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -162,6 +162,9 @@ }, "tools": { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": { "type": "boolean" } @@ -207,6 +210,9 @@ }, { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": { "type": "string", "enum": [ @@ -294,6 +300,9 @@ }, "providerOptions": { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": {} } }, @@ -335,6 +344,9 @@ }, "tools": { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": { "type": "boolean" } @@ -380,6 +392,9 @@ }, { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": { "type": "string", "enum": [ @@ -467,6 +482,9 @@ }, "providerOptions": { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": {} } }, @@ -508,6 +526,9 @@ }, "tools": { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": { "type": "boolean" } @@ -553,6 +574,9 @@ }, { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": { "type": "string", "enum": [ @@ -640,6 +664,9 @@ }, "providerOptions": { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": {} } }, @@ -681,6 +708,9 @@ }, "tools": { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": { "type": "boolean" } @@ -726,6 +756,9 @@ }, { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": { "type": "string", "enum": [ @@ -813,6 +846,9 @@ }, "providerOptions": { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": {} } }, @@ -854,6 +890,9 @@ }, "tools": { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": { "type": "boolean" } @@ -899,6 +938,9 @@ }, { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": { "type": "string", "enum": [ @@ -986,6 +1028,9 @@ }, "providerOptions": { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": {} } }, @@ -1027,6 +1072,9 @@ }, "tools": { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": { "type": "boolean" } @@ -1072,6 +1120,9 @@ }, { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": { "type": "string", "enum": [ @@ -1159,6 +1210,9 @@ }, "providerOptions": { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": {} } }, @@ -1200,6 +1254,9 @@ }, "tools": { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": { "type": "boolean" } @@ -1245,6 +1302,9 @@ }, { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": { "type": "string", "enum": [ @@ -1332,6 +1392,9 @@ }, "providerOptions": { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": {} } }, @@ -1373,6 +1436,9 @@ }, "tools": { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": { "type": "boolean" } @@ -1418,6 +1484,9 @@ }, { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": { "type": "string", "enum": [ @@ -1505,6 +1574,9 @@ }, "providerOptions": { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": {} } }, @@ -1546,6 +1618,9 @@ }, "tools": { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": { "type": "boolean" } @@ -1591,6 +1666,9 @@ }, { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": { "type": "string", "enum": [ @@ -1678,6 +1756,9 @@ }, "providerOptions": { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": {} } }, @@ -1719,6 +1800,9 @@ }, "tools": { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": { "type": "boolean" } @@ -1764,6 +1848,9 @@ }, { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": { "type": "string", "enum": [ @@ -1851,6 +1938,9 @@ }, "providerOptions": { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": {} } }, @@ -1892,6 +1982,9 @@ }, "tools": { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": { "type": "boolean" } @@ -1937,6 +2030,9 @@ }, { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": { "type": "string", "enum": [ @@ -2024,6 +2120,9 @@ }, "providerOptions": { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": {} } }, @@ -2065,6 +2164,9 @@ }, "tools": { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": { "type": "boolean" } @@ -2110,6 +2212,9 @@ }, { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": { "type": "string", "enum": [ @@ -2197,6 +2302,9 @@ }, "providerOptions": { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": {} } }, @@ -2238,6 +2346,9 @@ }, "tools": { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": { "type": "boolean" } @@ -2283,6 +2394,9 @@ }, { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": { "type": "string", "enum": [ @@ -2370,6 +2484,9 @@ }, "providerOptions": { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": {} } }, @@ -2411,6 +2528,9 @@ }, "tools": { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": { "type": "boolean" } @@ -2456,6 +2576,9 @@ }, { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": { "type": "string", "enum": [ @@ -2543,6 +2666,9 @@ }, "providerOptions": { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": {} } }, @@ -2553,6 +2679,9 @@ }, "categories": { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": { "type": "object", "properties": { @@ -2616,6 +2745,9 @@ }, "tools": { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": { "type": "boolean" } @@ -2656,6 +2788,9 @@ }, "plugins_override": { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": { "type": "boolean" } @@ -2926,6 +3061,9 @@ }, "metadata": { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": {} }, "allowed-tools": { @@ -2977,6 +3115,9 @@ }, "providerConcurrency": { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": { "type": "number", "minimum": 0 @@ -2984,6 +3125,9 @@ }, "modelConcurrency": { "type": "object", + "propertyNames": { + "type": "string" + }, "additionalProperties": { "type": "number", "minimum": 0 diff --git a/bun.lock b/bun.lock index 36c9e59d..dbac7140 100644 --- a/bun.lock +++ b/bun.lock @@ -28,13 +28,13 @@ "typescript": "^5.7.3", }, "optionalDependencies": { - "oh-my-opencode-darwin-arm64": "3.5.3", - "oh-my-opencode-darwin-x64": "3.5.3", - "oh-my-opencode-linux-arm64": "3.5.3", - "oh-my-opencode-linux-arm64-musl": "3.5.3", - "oh-my-opencode-linux-x64": "3.5.3", - "oh-my-opencode-linux-x64-musl": "3.5.3", - "oh-my-opencode-windows-x64": "3.5.3", + "oh-my-opencode-darwin-arm64": "3.5.5", + "oh-my-opencode-darwin-x64": "3.5.5", + "oh-my-opencode-linux-arm64": "3.5.5", + "oh-my-opencode-linux-arm64-musl": "3.5.5", + "oh-my-opencode-linux-x64": "3.5.5", + "oh-my-opencode-linux-x64-musl": "3.5.5", + "oh-my-opencode-windows-x64": "3.5.5", }, }, }, @@ -226,19 +226,19 @@ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - "oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.5.3", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Dq0+PC2dyAqG7c3DUnQmdOkKbKmOsRHwoqgLCQNKN1lTRllF8zbWqp5B+LGKxSPxPqJIPS3mKt+wIR2KvkYJVw=="], + "oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.5.5", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-XtcCQ8/iVT6T1B58y0N1oMgOK4beTW8DW98b/ITnINb7b3hNSv5754Af/2Rx67BV0iE0ezC6uXaqz45C7ru1rw=="], - "oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.5.3", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Ke45Bv/ygZm3YUSUumIyk647KZ2PFzw30tH597cOpG8MDPGbNVBCM6EKFezcukUPT+gPFVpE1IiGzEkn4JmgZA=="], + "oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.5.5", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-ReSDqU6jihh7lpGNmEt3REzc5bOcyfv3cMHitpecKq0wRrJoTBI+dgNPk90BLjHobGbhAm0TE8VZ9tqTkivnIQ=="], - "oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.5.3", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-aP5S3DngUhFkNeqYM33Ge6zccCWLzB/O3FLXLFXy/Iws03N8xugw72pnMK6lUbIia9QQBKK7IZBoYm9C79pZ3g=="], + "oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.5.5", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Zs/ETIxwcWBvw+jdlo8t+3+92oMMaXkFg1ZCuZrBRZOmtPFefdsH5/QEIe2TlNSjfoTwlA7cbpOD6oXgxRVrtg=="], - "oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.5.3", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-UiD/hVKYZQyX4D5N5SnZT4M5Z/B2SDtJWBW4MibpYSAcPKNCEBKi/5E4hOPxAtTfFGR8tIXFmYZdQJDkVfvluw=="], + "oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.5.5", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-m9r4OW1XhGtm/SvHM3kzpS4pEiI2eIh5Tj+j5hpMW3wu+AqE3F1XGUpu8RgvIpupFo8beimJWDYQujqokReQqg=="], - "oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.5.3", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-L9kqwzElGkaQ8pgtv1ZjcHARw9LPaU4UEVjzauByTMi+/5Js/PTsNXBggxSRzZfQ8/MNBPSCiA4K10Kc0YjjvA=="], + "oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.5.5", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-N6ysF5Pr2C1dyC5Dftzp05RJODgL+EYCWcOV59/UCV152cINlOhg80804o+6XTKV/taOAaboYaQwsBKiCs/BNQ=="], - "oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.5.3", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Z0fVVih/b2dbNeb9DK9oca5dNYCZyPySBRtxRhDXod5d7fJNgIPrvUoEd3SNfkRGORyFB3hGBZ6nqQ6N8+8DEA=="], + "oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.5.5", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-MOxW1FMTJT3Ze/U2fDedcZUYTFaA9PaKIiqtsBIHOSb+fFgdo51RIuUlKCELN/g9I9dYhw0yP2n9tBMBG6feSg=="], - "oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.5.3", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-ocWPjRs2sJgN02PJnEIYtqdMVDex1YhEj1FzAU5XIicfzQbgxLh9nz1yhHZzfqGJq69QStU6ofpc5kQpfX1LMg=="], + "oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.5.5", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-dWRtPyIdMFQIw1BwVO4PbGqoo0UWs7NES+YJC7BLGv0YnWN7Q2tatmOviSeSgMELeMsWSbDNisEB79jsfShXjA=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], diff --git a/package.json b/package.json index fc5c5a44..2276ecbb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode", - "version": "3.5.5", + "version": "3.5.6", "description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -74,13 +74,13 @@ "typescript": "^5.7.3" }, "optionalDependencies": { - "oh-my-opencode-darwin-arm64": "3.5.5", - "oh-my-opencode-darwin-x64": "3.5.5", - "oh-my-opencode-linux-arm64": "3.5.5", - "oh-my-opencode-linux-arm64-musl": "3.5.5", - "oh-my-opencode-linux-x64": "3.5.5", - "oh-my-opencode-linux-x64-musl": "3.5.5", - "oh-my-opencode-windows-x64": "3.5.5" + "oh-my-opencode-darwin-arm64": "3.5.6", + "oh-my-opencode-darwin-x64": "3.5.6", + "oh-my-opencode-linux-arm64": "3.5.6", + "oh-my-opencode-linux-arm64-musl": "3.5.6", + "oh-my-opencode-linux-x64": "3.5.6", + "oh-my-opencode-linux-x64-musl": "3.5.6", + "oh-my-opencode-windows-x64": "3.5.6" }, "trustedDependencies": [ "@ast-grep/cli", diff --git a/packages/darwin-arm64/package.json b/packages/darwin-arm64/package.json index 51ae742c..23dfd4ea 100644 --- a/packages/darwin-arm64/package.json +++ b/packages/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-darwin-arm64", - "version": "3.5.5", + "version": "3.5.6", "description": "Platform-specific binary for oh-my-opencode (darwin-arm64)", "license": "MIT", "repository": { diff --git a/packages/darwin-x64/package.json b/packages/darwin-x64/package.json index a1f68494..003c210b 100644 --- a/packages/darwin-x64/package.json +++ b/packages/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-darwin-x64", - "version": "3.5.5", + "version": "3.5.6", "description": "Platform-specific binary for oh-my-opencode (darwin-x64)", "license": "MIT", "repository": { diff --git a/packages/linux-arm64-musl/package.json b/packages/linux-arm64-musl/package.json index 85605b14..98a2bcac 100644 --- a/packages/linux-arm64-musl/package.json +++ b/packages/linux-arm64-musl/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-linux-arm64-musl", - "version": "3.5.5", + "version": "3.5.6", "description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)", "license": "MIT", "repository": { diff --git a/packages/linux-arm64/package.json b/packages/linux-arm64/package.json index f2996091..9a4d8aec 100644 --- a/packages/linux-arm64/package.json +++ b/packages/linux-arm64/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-linux-arm64", - "version": "3.5.5", + "version": "3.5.6", "description": "Platform-specific binary for oh-my-opencode (linux-arm64)", "license": "MIT", "repository": { diff --git a/packages/linux-x64-musl/package.json b/packages/linux-x64-musl/package.json index 20d5caf9..47fa3aae 100644 --- a/packages/linux-x64-musl/package.json +++ b/packages/linux-x64-musl/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-linux-x64-musl", - "version": "3.5.5", + "version": "3.5.6", "description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)", "license": "MIT", "repository": { diff --git a/packages/linux-x64/package.json b/packages/linux-x64/package.json index 35185a2a..da80de17 100644 --- a/packages/linux-x64/package.json +++ b/packages/linux-x64/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-linux-x64", - "version": "3.5.5", + "version": "3.5.6", "description": "Platform-specific binary for oh-my-opencode (linux-x64)", "license": "MIT", "repository": { diff --git a/packages/windows-x64/package.json b/packages/windows-x64/package.json index 881e8407..d8003b61 100644 --- a/packages/windows-x64/package.json +++ b/packages/windows-x64/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-windows-x64", - "version": "3.5.5", + "version": "3.5.6", "description": "Platform-specific binary for oh-my-opencode (windows-x64)", "license": "MIT", "repository": { diff --git a/signatures/cla.json b/signatures/cla.json index 6b2d20b8..78d45fc4 100644 --- a/signatures/cla.json +++ b/signatures/cla.json @@ -1511,6 +1511,14 @@ "created_at": "2026-02-15T15:07:11Z", "repoId": 1108837393, "pullRequestNo": 1864 + }, + { + "name": "dankochetov", + "id": 33990502, + "comment_id": 3905398332, + "created_at": "2026-02-15T23:17:05Z", + "repoId": 1108837393, + "pullRequestNo": 1870 } ] } \ No newline at end of file diff --git a/src/AGENTS.md b/src/AGENTS.md index 0724e41e..5c98a404 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -5,25 +5,26 @@ Main plugin entry point and orchestration layer. Plugin initialization, hook registration, tool composition, and lifecycle management. ## STRUCTURE + ``` src/ -├── index.ts # Main plugin entry (88 lines) — OhMyOpenCodePlugin factory +├── index.ts # Main plugin entry (106 lines) — OhMyOpenCodePlugin factory ├── create-hooks.ts # Hook coordination: core, continuation, skill (62 lines) ├── create-managers.ts # Manager initialization: Tmux, Background, SkillMcp, Config (80 lines) ├── create-tools.ts # Tool registry + skill context composition (54 lines) ├── plugin-interface.ts # Plugin interface assembly — 7 OpenCode hooks (66 lines) -├── plugin-config.ts # Config loading orchestration (user + project merge) -├── plugin-state.ts # Model cache state (context limits, anthropic 1M flag) -├── agents/ # 11 AI agents (32 files) - see agents/AGENTS.md -├── cli/ # CLI installer, doctor (107+ files) - see cli/AGENTS.md -├── config/ # Zod schema (21 component files) - see config/AGENTS.md -├── features/ # Background agents, skills, commands (18 dirs) - see features/AGENTS.md -├── hooks/ # 41 lifecycle hooks (36 dirs) - see hooks/AGENTS.md -├── mcp/ # Built-in MCPs (6 files) - see mcp/AGENTS.md +├── plugin-config.ts # Config loading orchestration (user + project merge, 180 lines) +├── plugin-state.ts # Model cache state (context limits, anthropic 1M flag, 12 lines) +├── agents/ # 11 AI agents (32 files) — see agents/AGENTS.md +├── cli/ # CLI installer, doctor (107+ files) — see cli/AGENTS.md +├── config/ # Zod schema (21 component files) — see config/AGENTS.md +├── features/ # Background agents, skills, commands (18 dirs) — see features/AGENTS.md +├── hooks/ # 41 lifecycle hooks (36 dirs) — see hooks/AGENTS.md +├── mcp/ # Built-in MCPs (6 files) — see mcp/AGENTS.md ├── plugin/ # Plugin interface composition (21 files) -├── plugin-handlers/ # Config loading, plan inheritance (15 files) - see plugin-handlers/AGENTS.md -├── shared/ # Cross-cutting utilities (84 files) - see shared/AGENTS.md -└── tools/ # 25+ tools (14 dirs) - see tools/AGENTS.md +├── plugin-handlers/ # Config loading, plan inheritance (15 files) — see plugin-handlers/AGENTS.md +├── shared/ # Cross-cutting utilities (96 files) — see shared/AGENTS.md +└── tools/ # 26 tools (14 dirs) — see tools/AGENTS.md ``` ## PLUGIN INITIALIZATION (10 steps) diff --git a/src/agents/AGENTS.md b/src/agents/AGENTS.md index 2ae8e4dd..4946b892 100644 --- a/src/agents/AGENTS.md +++ b/src/agents/AGENTS.md @@ -7,36 +7,22 @@ ## STRUCTURE ``` agents/ -├── sisyphus.ts # Main orchestrator (530 lines) -├── hephaestus.ts # Autonomous deep worker (624 lines) -├── oracle.ts # Strategic advisor (170 lines) -├── librarian.ts # Multi-repo research (328 lines) -├── explore.ts # Fast codebase grep (124 lines) -├── multimodal-looker.ts # Media analyzer (58 lines) +├── sisyphus.ts # Main orchestrator (559 lines) +├── hephaestus.ts # Autonomous deep worker (651 lines) +├── oracle.ts # Strategic advisor (171 lines) +├── librarian.ts # Multi-repo research (329 lines) +├── explore.ts # Fast codebase grep (125 lines) +├── multimodal-looker.ts # Media analyzer (59 lines) ├── metis.ts # Pre-planning analysis (347 lines) ├── momus.ts # Plan validator (244 lines) -├── atlas/ # Master orchestrator -│ ├── agent.ts # Atlas factory -│ ├── default.ts # Claude-optimized prompt -│ ├── gpt.ts # GPT-optimized prompt -│ └── utils.ts -├── prometheus/ # Planning agent -│ ├── index.ts -│ ├── system-prompt.ts # 6-section prompt assembly -│ ├── plan-template.ts # Work plan structure (423 lines) -│ ├── interview-mode.ts # Interview flow (335 lines) -│ ├── plan-generation.ts -│ ├── high-accuracy-mode.ts -│ ├── identity-constraints.ts # Identity rules (301 lines) -│ └── behavioral-summary.ts -├── sisyphus-junior/ # Delegated task executor -│ ├── agent.ts -│ ├── default.ts # Claude prompt -│ └── gpt.ts # GPT prompt -├── dynamic-agent-prompt-builder.ts # Dynamic prompt generation (431 lines) -├── builtin-agents/ # Agent registry (8 files) +├── atlas/ # Master orchestrator (agent.ts + default.ts + gpt.ts) +├── prometheus/ # Planning agent (8 files, plan-template 423 lines) +├── sisyphus-junior/ # Delegated task executor (agent.ts + default.ts + gpt.ts) +├── dynamic-agent-prompt-builder.ts # Dynamic prompt generation (433 lines) +├── builtin-agents/ # Agent registry + model resolution +├── agent-builder.ts # Agent construction with category merging (51 lines) ├── utils.ts # Agent creation, model fallback resolution (571 lines) -├── types.ts # AgentModelConfig, AgentPromptMetadata +├── types.ts # AgentModelConfig, AgentPromptMetadata (106 lines) └── index.ts # Exports ``` @@ -78,6 +64,12 @@ agents/ | Momus | 32k budget tokens | reasoningEffort: "medium" | | Sisyphus-Junior | 32k budget tokens | reasoningEffort: "medium" | +## KEY PROMPT PATTERNS + +- **Sisyphus/Hephaestus**: Dynamic prompts via `dynamic-agent-prompt-builder.ts` injecting available tools/skills/categories +- **Atlas, Sisyphus-Junior**: Model-specific prompts (Claude vs GPT variants) +- **Prometheus**: 6-section modular prompt (identity → interview → plan-generation → high-accuracy → template → behavioral) + ## HOW TO ADD 1. Create `src/agents/my-agent.ts` exporting factory + metadata @@ -85,13 +77,6 @@ agents/ 3. Update `AgentNameSchema` in `src/config/schema/agent-names.ts` 4. Register in `src/plugin-handlers/agent-config-handler.ts` -## KEY PATTERNS - -- **Factory**: `createXXXAgent(model): AgentConfig` -- **Metadata**: `XXX_PROMPT_METADATA` with category, cost, triggers -- **Model-specific prompts**: Atlas, Sisyphus-Junior have GPT vs Claude variants -- **Dynamic prompts**: Sisyphus, Hephaestus use `dynamic-agent-prompt-builder.ts` to inject available tools/skills/categories - ## ANTI-PATTERNS - **Trust agent self-reports**: NEVER — always verify outputs diff --git a/src/agents/prometheus-prompt.test.ts b/src/agents/prometheus-prompt.test.ts index 625b4250..266b8f80 100644 --- a/src/agents/prometheus-prompt.test.ts +++ b/src/agents/prometheus-prompt.test.ts @@ -66,7 +66,7 @@ describe("PROMETHEUS_SYSTEM_PROMPT zero human intervention", () => { expect(lowerPrompt).toContain("preconditions") expect(lowerPrompt).toContain("failure indicators") expect(lowerPrompt).toContain("evidence") - expect(lowerPrompt).toMatch(/negative scenario/) + expect(prompt).toMatch(/negative/i) }) test("should require QA scenario adequacy in self-review checklist", () => { diff --git a/src/agents/prometheus/identity-constraints.ts b/src/agents/prometheus/identity-constraints.ts index c8db667c..af16243a 100644 --- a/src/agents/prometheus/identity-constraints.ts +++ b/src/agents/prometheus/identity-constraints.ts @@ -129,7 +129,21 @@ Your ONLY valid output locations are \`.sisyphus/plans/*.md\` and \`.sisyphus/dr Example: \`.sisyphus/plans/auth-refactor.md\` -### 5. SINGLE PLAN MANDATE (CRITICAL) +### 5. MAXIMUM PARALLELISM PRINCIPLE (NON-NEGOTIABLE) + +Your plans MUST maximize parallel execution. This is a core planning quality metric. + +**Granularity Rule**: One task = one module/concern = 1-3 files. +If a task touches 4+ files or 2+ unrelated concerns, SPLIT IT. + +**Parallelism Target**: Aim for 5-8 tasks per wave. +If any wave has fewer than 3 tasks (except the final integration), you under-split. + +**Dependency Minimization**: Structure tasks so shared dependencies +(types, interfaces, configs) are extracted as early Wave-1 tasks, +unblocking maximum parallelism in subsequent waves. + +### 6. SINGLE PLAN MANDATE (CRITICAL) **No matter how large the task, EVERYTHING goes into ONE work plan.** **NEVER:** @@ -152,7 +166,7 @@ Example: \`.sisyphus/plans/auth-refactor.md\` **The plan can have 50+ TODOs. That's OK. ONE PLAN.** -### 5.1 SINGLE ATOMIC WRITE (CRITICAL - Prevents Content Loss) +### 6.1 SINGLE ATOMIC WRITE (CRITICAL - Prevents Content Loss) **The Write tool OVERWRITES files. It does NOT append.** @@ -188,7 +202,7 @@ Example: \`.sisyphus/plans/auth-refactor.md\` - [ ] File already exists with my content? → Use Edit to append, NOT Write -### 6. DRAFT AS WORKING MEMORY (MANDATORY) +### 7. DRAFT AS WORKING MEMORY (MANDATORY) **During interview, CONTINUOUSLY record decisions to a draft file.** **Draft Location**: \`.sisyphus/drafts/{name}.md\` diff --git a/src/agents/prometheus/plan-template.ts b/src/agents/prometheus/plan-template.ts index 75c9ced9..42c16fca 100644 --- a/src/agents/prometheus/plan-template.ts +++ b/src/agents/prometheus/plan-template.ts @@ -70,108 +70,25 @@ Generate plan to: \`.sisyphus/plans/{name}.md\` ## Verification Strategy (MANDATORY) -> **UNIVERSAL RULE: ZERO HUMAN INTERVENTION** -> -> ALL tasks in this plan MUST be verifiable WITHOUT any human action. -> This is NOT conditional — it applies to EVERY task, regardless of test strategy. -> -> **FORBIDDEN** — acceptance criteria that require: -> - "User manually tests..." / "사용자가 직접 테스트..." -> - "User visually confirms..." / "사용자가 눈으로 확인..." -> - "User interacts with..." / "사용자가 직접 조작..." -> - "Ask user to verify..." / "사용자에게 확인 요청..." -> - ANY step where a human must perform an action -> -> **ALL verification is executed by the agent** using tools (Playwright, interactive_bash, curl, etc.). No exceptions. +> **ZERO HUMAN INTERVENTION** — ALL verification is agent-executed. No exceptions. +> Acceptance criteria requiring "user manually tests/confirms" are FORBIDDEN. ### Test Decision - **Infrastructure exists**: [YES/NO] - **Automated tests**: [TDD / Tests-after / None] - **Framework**: [bun test / vitest / jest / pytest / none] +- **If TDD**: Each task follows RED (failing test) → GREEN (minimal impl) → REFACTOR -### If TDD Enabled +### QA Policy +Every task MUST include agent-executed QA scenarios (see TODO template below). +Evidence saved to \`.sisyphus/evidence/task-{N}-{scenario-slug}.{ext}\`. -Each TODO follows RED-GREEN-REFACTOR: - -**Task Structure:** -1. **RED**: Write failing test first - - Test file: \`[path].test.ts\` - - Test command: \`bun test [file]\` - - Expected: FAIL (test exists, implementation doesn't) -2. **GREEN**: Implement minimum code to pass - - Command: \`bun test [file]\` - - Expected: PASS -3. **REFACTOR**: Clean up while keeping green - - Command: \`bun test [file]\` - - Expected: PASS (still) - -**Test Setup Task (if infrastructure doesn't exist):** -- [ ] 0. Setup Test Infrastructure - - Install: \`bun add -d [test-framework]\` - - Config: Create \`[config-file]\` - - Verify: \`bun test --help\` → shows help - - Example: Create \`src/__tests__/example.test.ts\` - - Verify: \`bun test\` → 1 test passes - -### Agent-Executed QA Scenarios (MANDATORY — ALL tasks) - -> Whether TDD is enabled or not, EVERY task MUST include Agent-Executed QA Scenarios. -> - **With TDD**: QA scenarios complement unit tests at integration/E2E level -> - **Without TDD**: QA scenarios are the PRIMARY verification method -> -> These describe how the executing agent DIRECTLY verifies the deliverable -> by running it — opening browsers, executing commands, sending API requests. -> The agent performs what a human tester would do, but automated via tools. - -**Verification Tool by Deliverable Type:** - -| Type | Tool | How Agent Verifies | -|------|------|-------------------| -| **Frontend/UI** | Playwright (playwright skill) | Navigate, interact, assert DOM, screenshot | -| **TUI/CLI** | interactive_bash (tmux) | Run command, send keystrokes, validate output | -| **API/Backend** | Bash (curl/httpie) | Send requests, parse responses, assert fields | -| **Library/Module** | Bash (bun/node REPL) | Import, call functions, compare output | -| **Config/Infra** | Bash (shell commands) | Apply config, run state checks, validate | - -**Each Scenario MUST Follow This Format:** - -\`\`\` -Scenario: [Descriptive name — what user action/flow is being verified] - Tool: [Playwright / interactive_bash / Bash] - Preconditions: [What must be true before this scenario runs] - Steps: - 1. [Exact action with specific selector/command/endpoint] - 2. [Next action with expected intermediate state] - 3. [Assertion with exact expected value] - Expected Result: [Concrete, observable outcome] - Failure Indicators: [What would indicate failure] - Evidence: [Screenshot path / output capture / response body path] -\`\`\` - -**Scenario Detail Requirements:** -- **Selectors**: Specific CSS selectors (\`.login-button\`, not "the login button") -- **Data**: Concrete test data (\`"test@example.com"\`, not \`"[email]"\`) -- **Assertions**: Exact values (\`text contains "Welcome back"\`, not "verify it works") -- **Timing**: Include wait conditions where relevant (\`Wait for .dashboard (timeout: 10s)\`) -- **Negative Scenarios**: At least ONE failure/error scenario per feature -- **Evidence Paths**: Specific file paths (\`.sisyphus/evidence/task-N-scenario-name.png\`) - -**Anti-patterns (NEVER write scenarios like this):** -- ❌ "Verify the login page works correctly" -- ❌ "Check that the API returns the right data" -- ❌ "Test the form validation" -- ❌ "User opens browser and confirms..." - -**Write scenarios like this instead:** -- ✅ \`Navigate to /login → Fill input[name="email"] with "test@example.com" → Fill input[name="password"] with "Pass123!" → Click button[type="submit"] → Wait for /dashboard → Assert h1 contains "Welcome"\` -- ✅ \`POST /api/users {"name":"Test","email":"new@test.com"} → Assert status 201 → Assert response.id is UUID → GET /api/users/{id} → Assert name equals "Test"\` -- ✅ \`Run ./cli --config test.yaml → Wait for "Loaded" in stdout → Send "q" → Assert exit code 0 → Assert stdout contains "Goodbye"\` - -**Evidence Requirements:** -- Screenshots: \`.sisyphus/evidence/\` for all UI verifications -- Terminal output: Captured for CLI/TUI verifications -- Response bodies: Saved for API verifications -- All evidence referenced by specific file path in acceptance criteria +| Deliverable Type | Verification Tool | Method | +|------------------|-------------------|--------| +| Frontend/UI | Playwright (playwright skill) | Navigate, interact, assert DOM, screenshot | +| TUI/CLI | interactive_bash (tmux) | Run command, send keystrokes, validate output | +| API/Backend | Bash (curl) | Send requests, assert status + response fields | +| Library/Module | Bash (bun/node REPL) | Import, call functions, compare output | --- @@ -181,49 +98,82 @@ Scenario: [Descriptive name — what user action/flow is being verified] > Maximize throughput by grouping independent tasks into parallel waves. > Each wave completes before the next begins. +> Target: 5-8 tasks per wave. Fewer than 3 per wave (except final) = under-splitting. \`\`\` -Wave 1 (Start Immediately): -├── Task 1: [no dependencies] -└── Task 5: [no dependencies] +Wave 1 (Start Immediately — foundation + scaffolding): +├── Task 1: Project scaffolding + config [quick] +├── Task 2: Design system tokens [quick] +├── Task 3: Type definitions [quick] +├── Task 4: Schema definitions [quick] +├── Task 5: Storage interface + in-memory impl [quick] +├── Task 6: Auth middleware [quick] +└── Task 7: Client module [quick] -Wave 2 (After Wave 1): -├── Task 2: [depends: 1] -├── Task 3: [depends: 1] -└── Task 6: [depends: 5] +Wave 2 (After Wave 1 — core modules, MAX PARALLEL): +├── Task 8: Core business logic (depends: 3, 5, 7) [deep] +├── Task 9: API endpoints (depends: 4, 5) [unspecified-high] +├── Task 10: Secondary storage impl (depends: 5) [unspecified-high] +├── Task 11: Retry/fallback logic (depends: 8) [deep] +├── Task 12: UI layout + navigation (depends: 2) [visual-engineering] +├── Task 13: API client + hooks (depends: 4) [quick] +└── Task 14: Telemetry middleware (depends: 5, 10) [unspecified-high] -Wave 3 (After Wave 2): -└── Task 4: [depends: 2, 3] +Wave 3 (After Wave 2 — integration + UI): +├── Task 15: Main route combining modules (depends: 6, 11, 14) [deep] +├── Task 16: UI data visualization (depends: 12, 13) [visual-engineering] +├── Task 17: Deployment config A (depends: 15) [quick] +├── Task 18: Deployment config B (depends: 15) [quick] +├── Task 19: Deployment config C (depends: 15) [quick] +└── Task 20: UI request log + build (depends: 16) [visual-engineering] -Critical Path: Task 1 → Task 2 → Task 4 -Parallel Speedup: ~40% faster than sequential +Wave 4 (After Wave 3 — verification): +├── Task 21: Integration tests (depends: 15) [deep] +├── Task 22: UI QA - Playwright (depends: 20) [unspecified-high] +├── Task 23: E2E QA (depends: 21) [deep] +└── Task 24: Git cleanup + tagging (depends: 21) [git] + +Wave FINAL (After ALL tasks — independent review, 4 parallel): +├── Task F1: Plan compliance audit (oracle) +├── Task F2: Code quality review (unspecified-high) +├── Task F3: Real manual QA (unspecified-high) +└── Task F4: Scope fidelity check (deep) + +Critical Path: Task 1 → Task 5 → Task 8 → Task 11 → Task 15 → Task 21 → F1-F4 +Parallel Speedup: ~70% faster than sequential +Max Concurrent: 7 (Waves 1 & 2) \`\`\` -### Dependency Matrix +### Dependency Matrix (abbreviated — show ALL tasks in your generated plan) -| Task | Depends On | Blocks | Can Parallelize With | -|------|------------|--------|---------------------| -| 1 | None | 2, 3 | 5 | -| 2 | 1 | 4 | 3, 6 | -| 3 | 1 | 4 | 2, 6 | -| 4 | 2, 3 | None | None (final) | -| 5 | None | 6 | 1 | -| 6 | 5 | None | 2, 3 | +| Task | Depends On | Blocks | Wave | +|------|------------|--------|------| +| 1-7 | — | 8-14 | 1 | +| 8 | 3, 5, 7 | 11, 15 | 2 | +| 11 | 8 | 15 | 2 | +| 14 | 5, 10 | 15 | 2 | +| 15 | 6, 11, 14 | 17-19, 21 | 3 | +| 21 | 15 | 23, 24 | 4 | + +> This is abbreviated for reference. YOUR generated plan must include the FULL matrix for ALL tasks. ### Agent Dispatch Summary -| Wave | Tasks | Recommended Agents | -|------|-------|-------------------| -| 1 | 1, 5 | task(category="...", load_skills=[...], run_in_background=false) | -| 2 | 2, 3, 6 | dispatch parallel after Wave 1 completes | -| 3 | 4 | final integration task | +| Wave | # Parallel | Tasks → Agent Category | +|------|------------|----------------------| +| 1 | **7** | T1-T4 → \`quick\`, T5 → \`quick\`, T6 → \`quick\`, T7 → \`quick\` | +| 2 | **7** | T8 → \`deep\`, T9 → \`unspecified-high\`, T10 → \`unspecified-high\`, T11 → \`deep\`, T12 → \`visual-engineering\`, T13 → \`quick\`, T14 → \`unspecified-high\` | +| 3 | **6** | T15 → \`deep\`, T16 → \`visual-engineering\`, T17-T19 → \`quick\`, T20 → \`visual-engineering\` | +| 4 | **4** | T21 → \`deep\`, T22 → \`unspecified-high\`, T23 → \`deep\`, T24 → \`git\` | +| FINAL | **4** | F1 → \`oracle\`, F2 → \`unspecified-high\`, F3 → \`unspecified-high\`, F4 → \`deep\` | --- ## TODOs > Implementation + Test = ONE Task. Never separate. -> EVERY task MUST have: Recommended Agent Profile + Parallelization info. +> EVERY task MUST have: Recommended Agent Profile + Parallelization info + QA Scenarios. +> **A task WITHOUT QA Scenarios is INCOMPLETE. No exceptions.** - [ ] 1. [Task Title] @@ -257,22 +207,15 @@ Parallel Speedup: ~40% faster than sequential **Pattern References** (existing code to follow): - \`src/services/auth.ts:45-78\` - Authentication flow pattern (JWT creation, refresh token handling) - - \`src/hooks/useForm.ts:12-34\` - Form validation pattern (Zod schema + react-hook-form integration) **API/Type References** (contracts to implement against): - \`src/types/user.ts:UserDTO\` - Response shape for user endpoints - - \`src/api/schema.ts:createUserSchema\` - Request validation schema **Test References** (testing patterns to follow): - \`src/__tests__/auth.test.ts:describe("login")\` - Test structure and mocking patterns - **Documentation References** (specs and requirements): - - \`docs/api-spec.md#authentication\` - API contract details - - \`ARCHITECTURE.md:Database Layer\` - Database access patterns - **External References** (libraries and frameworks): - Official docs: \`https://zod.dev/?id=basic-usage\` - Zod validation syntax - - Example repo: \`github.com/example/project/src/auth\` - Reference implementation **WHY Each Reference Matters** (explain the relevance): - Don't just list files - explain what pattern/information the executor should extract @@ -283,113 +226,60 @@ Parallel Speedup: ~40% faster than sequential > **AGENT-EXECUTABLE VERIFICATION ONLY** — No human action permitted. > Every criterion MUST be verifiable by running a command or using a tool. - > REPLACE all placeholders with actual values from task context. **If TDD (tests enabled):** - [ ] Test file created: src/auth/login.test.ts - - [ ] Test covers: successful login returns JWT token - [ ] bun test src/auth/login.test.ts → PASS (3 tests, 0 failures) - **Agent-Executed QA Scenarios (MANDATORY — per-scenario, ultra-detailed):** + **QA Scenarios (MANDATORY — task is INCOMPLETE without these):** - > Write MULTIPLE named scenarios per task: happy path AND failure cases. - > Each scenario = exact tool + steps with real selectors/data + evidence path. - - **Example — Frontend/UI (Playwright):** + > **This is NOT optional. A task without QA scenarios WILL BE REJECTED.** + > + > Write scenario tests that verify the ACTUAL BEHAVIOR of what you built. + > Minimum: 1 happy path + 1 failure/edge case per task. + > Each scenario = exact tool + exact steps + exact assertions + evidence path. + > + > **The executing agent MUST run these scenarios after implementation.** + > **The orchestrator WILL verify evidence files exist before marking task complete.** \\\`\\\`\\\` - Scenario: Successful login redirects to dashboard - Tool: Playwright (playwright skill) - Preconditions: Dev server running on localhost:3000, test user exists + Scenario: [Happy path — what SHOULD work] + Tool: [Playwright / interactive_bash / Bash (curl)] + Preconditions: [Exact setup state] Steps: - 1. Navigate to: http://localhost:3000/login - 2. Wait for: input[name="email"] visible (timeout: 5s) - 3. Fill: input[name="email"] → "test@example.com" - 4. Fill: input[name="password"] → "ValidPass123!" - 5. Click: button[type="submit"] - 6. Wait for: navigation to /dashboard (timeout: 10s) - 7. Assert: h1 text contains "Welcome back" - 8. Assert: cookie "session_token" exists - 9. Screenshot: .sisyphus/evidence/task-1-login-success.png - Expected Result: Dashboard loads with welcome message - Evidence: .sisyphus/evidence/task-1-login-success.png + 1. [Exact action — specific command/selector/endpoint, no vagueness] + 2. [Next action — with expected intermediate state] + 3. [Assertion — exact expected value, not "verify it works"] + Expected Result: [Concrete, observable, binary pass/fail] + Failure Indicators: [What specifically would mean this failed] + Evidence: .sisyphus/evidence/task-{N}-{scenario-slug}.{ext} - Scenario: Login fails with invalid credentials - Tool: Playwright (playwright skill) - Preconditions: Dev server running, no valid user with these credentials + Scenario: [Failure/edge case — what SHOULD fail gracefully] + Tool: [same format] + Preconditions: [Invalid input / missing dependency / error state] Steps: - 1. Navigate to: http://localhost:3000/login - 2. Fill: input[name="email"] → "wrong@example.com" - 3. Fill: input[name="password"] → "WrongPass" - 4. Click: button[type="submit"] - 5. Wait for: .error-message visible (timeout: 5s) - 6. Assert: .error-message text contains "Invalid credentials" - 7. Assert: URL is still /login (no redirect) - 8. Screenshot: .sisyphus/evidence/task-1-login-failure.png - Expected Result: Error message shown, stays on login page - Evidence: .sisyphus/evidence/task-1-login-failure.png + 1. [Trigger the error condition] + 2. [Assert error is handled correctly] + Expected Result: [Graceful failure with correct error message/code] + Evidence: .sisyphus/evidence/task-{N}-{scenario-slug}-error.{ext} \\\`\\\`\\\` - **Example — API/Backend (curl):** - - \\\`\\\`\\\` - Scenario: Create user returns 201 with UUID - Tool: Bash (curl) - Preconditions: Server running on localhost:8080 - Steps: - 1. curl -s -w "\\n%{http_code}" -X POST http://localhost:8080/api/users \\ - -H "Content-Type: application/json" \\ - -d '{"email":"new@test.com","name":"Test User"}' - 2. Assert: HTTP status is 201 - 3. Assert: response.id matches UUID format - 4. GET /api/users/{returned-id} → Assert name equals "Test User" - Expected Result: User created and retrievable - Evidence: Response bodies captured - - Scenario: Duplicate email returns 409 - Tool: Bash (curl) - Preconditions: User with email "new@test.com" already exists - Steps: - 1. Repeat POST with same email - 2. Assert: HTTP status is 409 - 3. Assert: response.error contains "already exists" - Expected Result: Conflict error returned - Evidence: Response body captured - \\\`\\\`\\\` - - **Example — TUI/CLI (interactive_bash):** - - \\\`\\\`\\\` - Scenario: CLI loads config and displays menu - Tool: interactive_bash (tmux) - Preconditions: Binary built, test config at ./test.yaml - Steps: - 1. tmux new-session: ./my-cli --config test.yaml - 2. Wait for: "Configuration loaded" in output (timeout: 5s) - 3. Assert: Menu items visible ("1. Create", "2. List", "3. Exit") - 4. Send keys: "3" then Enter - 5. Assert: "Goodbye" in output - 6. Assert: Process exited with code 0 - Expected Result: CLI starts, shows menu, exits cleanly - Evidence: Terminal output captured - - Scenario: CLI handles missing config gracefully - Tool: interactive_bash (tmux) - Preconditions: No config file at ./nonexistent.yaml - Steps: - 1. tmux new-session: ./my-cli --config nonexistent.yaml - 2. Wait for: output (timeout: 3s) - 3. Assert: stderr contains "Config file not found" - 4. Assert: Process exited with code 1 - Expected Result: Meaningful error, non-zero exit - Evidence: Error output captured - \\\`\\\`\\\` + > **Specificity requirements — every scenario MUST use:** + > - **Selectors**: Specific CSS selectors (\`.login-button\`, not "the login button") + > - **Data**: Concrete test data (\`"test@example.com"\`, not \`"[email]"\`) + > - **Assertions**: Exact values (\`text contains "Welcome back"\`, not "verify it works") + > - **Timing**: Wait conditions where relevant (\`timeout: 10s\`) + > - **Negative**: At least ONE failure/error scenario per task + > + > **Anti-patterns (your scenario is INVALID if it looks like this):** + > - ❌ "Verify it works correctly" — HOW? What does "correctly" mean? + > - ❌ "Check the API returns data" — WHAT data? What fields? What values? + > - ❌ "Test the component renders" — WHERE? What selector? What content? + > - ❌ Any scenario without an evidence path **Evidence to Capture:** - - [ ] Screenshots in .sisyphus/evidence/ for UI scenarios - - [ ] Terminal output for CLI/TUI scenarios - - [ ] Response bodies for API scenarios - [ ] Each evidence file named: task-{N}-{scenario-slug}.{ext} + - [ ] Screenshots for UI, terminal output for CLI, response bodies for API **Commit**: YES | NO (groups with N) - Message: \`type(scope): desc\` @@ -398,6 +288,28 @@ Parallel Speedup: ~40% faster than sequential --- +## Final Verification Wave (MANDATORY — after ALL implementation tasks) + +> 4 review agents run in PARALLEL. ALL must APPROVE. Rejection → fix → re-run. + +- [ ] F1. **Plan Compliance Audit** — \`oracle\` + Read the plan end-to-end. For each "Must Have": verify implementation exists (read file, curl endpoint, run command). For each "Must NOT Have": search codebase for forbidden patterns — reject with file:line if found. Check evidence files exist in .sisyphus/evidence/. Compare deliverables against plan. + Output: \`Must Have [N/N] | Must NOT Have [N/N] | Tasks [N/N] | VERDICT: APPROVE/REJECT\` + +- [ ] F2. **Code Quality Review** — \`unspecified-high\` + Run \`tsc --noEmit\` + linter + \`bun test\`. Review all changed files for: \`as any\`/\`@ts-ignore\`, empty catches, console.log in prod, commented-out code, unused imports. Check AI slop: excessive comments, over-abstraction, generic names (data/result/item/temp). + Output: \`Build [PASS/FAIL] | Lint [PASS/FAIL] | Tests [N pass/N fail] | Files [N clean/N issues] | VERDICT\` + +- [ ] F3. **Real Manual QA** — \`unspecified-high\` (+ \`playwright\` skill if UI) + Start from clean state. Execute EVERY QA scenario from EVERY task — follow exact steps, capture evidence. Test cross-task integration (features working together, not isolation). Test edge cases: empty state, invalid input, rapid actions. Save to \`.sisyphus/evidence/final-qa/\`. + Output: \`Scenarios [N/N pass] | Integration [N/N] | Edge Cases [N tested] | VERDICT\` + +- [ ] F4. **Scope Fidelity Check** — \`deep\` + For each task: read "What to do", read actual diff (git log/diff). Verify 1:1 — everything in spec was built (no missing), nothing beyond spec was built (no creep). Check "Must NOT do" compliance. Detect cross-task contamination: Task N touching Task M's files. Flag unaccounted changes. + Output: \`Tasks [N/N compliant] | Contamination [CLEAN/N issues] | Unaccounted [CLEAN/N files] | VERDICT\` + +--- + ## Commit Strategy | After Task | Message | Files | Verification | diff --git a/src/cli/AGENTS.md b/src/cli/AGENTS.md index 46f177a9..5ac159ab 100644 --- a/src/cli/AGENTS.md +++ b/src/cli/AGENTS.md @@ -2,9 +2,7 @@ ## OVERVIEW -CLI entry: `bunx oh-my-opencode`. 107+ files with Commander.js + @clack/prompts TUI. - -**Commands**: install, run, doctor, get-local-version, mcp-oauth +CLI entry: `bunx oh-my-opencode`. 107+ files with Commander.js + @clack/prompts TUI. 5 commands: install, run, doctor, get-local-version, mcp-oauth. ## STRUCTURE ``` @@ -14,20 +12,22 @@ cli/ ├── install.ts # TTY routing (TUI or CLI installer) ├── cli-installer.ts # Non-interactive installer (164 lines) ├── tui-installer.ts # Interactive TUI with @clack/prompts (140 lines) -├── config-manager/ # 17 config utilities +├── config-manager/ # 20 config utilities │ ├── add-plugin-to-opencode-config.ts # Plugin registration -│ ├── add-provider-config.ts # Provider setup -│ ├── detect-current-config.ts # Project vs user config +│ ├── add-provider-config.ts # Provider setup (Google/Antigravity) +│ ├── detect-current-config.ts # Installed providers detection │ ├── write-omo-config.ts # JSONC writing -│ └── ... -├── doctor/ # 14 health checks -│ ├── runner.ts # Check orchestration -│ ├── formatter.ts # Colored output -│ └── checks/ # 29 files: auth, config, dependencies, gh, lsp, mcp, opencode, plugin, version, model-resolution (6 sub-checks) +│ ├── generate-omo-config.ts # Config generation +│ ├── jsonc-provider-editor.ts # JSONC editing +│ └── ... # 14 more utilities +├── doctor/ # 4 check categories, 21 check files +│ ├── runner.ts # Parallel check execution + result aggregation +│ ├── formatter.ts # Colored output (default/status/verbose/JSON) +│ └── checks/ # system (4), config (1), tools (4), models (6 sub-checks) ├── run/ # Session launcher (24 files) │ ├── runner.ts # Run orchestration (126 lines) -│ ├── agent-resolver.ts # Agent selection: flag → env → config → fallback -│ ├── session-resolver.ts # Session creation or resume +│ ├── agent-resolver.ts # Agent: flag → env → config → Sisyphus +│ ├── session-resolver.ts # Session create or resume with retries │ ├── event-handlers.ts # Event processing (125 lines) │ ├── completion.ts # Completion detection │ └── poll-for-completion.ts # Polling with timeout @@ -43,20 +43,17 @@ cli/ |---------|---------|-----------| | `install` | Interactive setup | Provider selection → config generation → plugin registration | | `run` | Session launcher | Agent: flag → env → config → Sisyphus. Enforces todo completion. | -| `doctor` | 14 health checks | installation, config, auth, deps, tools, updates | +| `doctor` | 4-category health checks | system, config, tools, models (6 sub-checks) | | `get-local-version` | Version check | Detects installed, compares with npm latest | | `mcp-oauth` | OAuth tokens | login (PKCE flow), logout, status | -## DOCTOR CHECK CATEGORIES +## RUN SESSION LIFECYCLE -| Category | Checks | -|----------|--------| -| installation | opencode, plugin | -| configuration | config validity, Zod, model-resolution (6 sub-checks) | -| authentication | anthropic, openai, google | -| dependencies | ast-grep, comment-checker, gh-cli | -| tools | LSP, MCP, MCP-OAuth | -| updates | version comparison | +1. Load config, resolve agent (CLI > env > config > Sisyphus) +2. Create server connection (port/attach), setup cleanup/signal handlers +3. Resolve session (create new or resume with retries) +4. Send prompt, start event processing, poll for completion +5. Execute on-complete hook, output JSON if requested, cleanup ## HOW TO ADD CHECK diff --git a/src/cli/cli-installer.test.ts b/src/cli/cli-installer.test.ts new file mode 100644 index 00000000..2320d951 --- /dev/null +++ b/src/cli/cli-installer.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test" +import * as configManager from "./config-manager" +import { runCliInstaller } from "./cli-installer" +import type { InstallArgs } from "./types" + +describe("runCliInstaller", () => { + const mockConsoleLog = mock(() => {}) + const mockConsoleError = mock(() => {}) + const originalConsoleLog = console.log + const originalConsoleError = console.error + + beforeEach(() => { + console.log = mockConsoleLog + console.error = mockConsoleError + mockConsoleLog.mockClear() + mockConsoleError.mockClear() + }) + + afterEach(() => { + console.log = originalConsoleLog + console.error = originalConsoleError + }) + + it("runs auth and provider setup steps when openai or copilot are enabled without gemini", async () => { + //#given + const addAuthPluginsSpy = spyOn(configManager, "addAuthPlugins").mockResolvedValue({ + success: true, + configPath: "/tmp/opencode.jsonc", + }) + const addProviderConfigSpy = spyOn(configManager, "addProviderConfig").mockReturnValue({ + success: true, + configPath: "/tmp/opencode.jsonc", + }) + const restoreSpies = [ + addAuthPluginsSpy, + addProviderConfigSpy, + spyOn(configManager, "detectCurrentConfig").mockReturnValue({ + isInstalled: false, + hasClaude: false, + isMax20: false, + hasOpenAI: false, + hasGemini: false, + hasCopilot: false, + hasOpencodeZen: false, + hasZaiCodingPlan: false, + hasKimiForCoding: false, + }), + spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true), + spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.0.200"), + spyOn(configManager, "addPluginToOpenCodeConfig").mockResolvedValue({ + success: true, + configPath: "/tmp/opencode.jsonc", + }), + spyOn(configManager, "writeOmoConfig").mockReturnValue({ + success: true, + configPath: "/tmp/oh-my-opencode.jsonc", + }), + ] + + const args: InstallArgs = { + tui: false, + claude: "no", + openai: "yes", + gemini: "no", + copilot: "yes", + opencodeZen: "no", + zaiCodingPlan: "no", + kimiForCoding: "no", + } + + //#when + const result = await runCliInstaller(args, "3.4.0") + + //#then + expect(result).toBe(0) + expect(addAuthPluginsSpy).toHaveBeenCalledTimes(1) + expect(addProviderConfigSpy).toHaveBeenCalledTimes(1) + + for (const spy of restoreSpies) { + spy.mockRestore() + } + }) +}) diff --git a/src/cli/cli-installer.ts b/src/cli/cli-installer.ts index a38b2c80..141e694c 100644 --- a/src/cli/cli-installer.ts +++ b/src/cli/cli-installer.ts @@ -77,7 +77,9 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi `Plugin ${isUpdate ? "verified" : "added"} ${SYMBOLS.arrow} ${color.dim(pluginResult.configPath)}`, ) - if (config.hasGemini) { + const needsProviderSetup = config.hasGemini || config.hasOpenAI || config.hasCopilot + + if (needsProviderSetup) { printStep(step++, totalSteps, "Adding auth plugins...") const authResult = await addAuthPlugins(config) if (!authResult.success) { diff --git a/src/cli/run/completion.ts b/src/cli/run/completion.ts index 11a24f4b..f339e9d2 100644 --- a/src/cli/run/completion.ts +++ b/src/cli/run/completion.ts @@ -1,5 +1,6 @@ import pc from "picocolors" import type { RunContext, Todo, ChildSession, SessionStatus } from "./types" +import { normalizeSDKResponse } from "../../shared" export async function checkCompletionConditions(ctx: RunContext): Promise { try { @@ -20,7 +21,7 @@ export async function checkCompletionConditions(ctx: RunContext): Promise { const todosRes = await ctx.client.session.todo({ path: { id: ctx.sessionID } }) - const todos = (todosRes.data ?? []) as Todo[] + const todos = normalizeSDKResponse(todosRes, [] as Todo[]) const incompleteTodos = todos.filter( (t) => t.status !== "completed" && t.status !== "cancelled" @@ -43,7 +44,7 @@ async function fetchAllStatuses( ctx: RunContext ): Promise> { const statusRes = await ctx.client.session.status() - return (statusRes.data ?? {}) as Record + return normalizeSDKResponse(statusRes, {} as Record) } async function areAllDescendantsIdle( @@ -54,7 +55,7 @@ async function areAllDescendantsIdle( const childrenRes = await ctx.client.session.children({ path: { id: sessionID }, }) - const children = (childrenRes.data ?? []) as ChildSession[] + const children = normalizeSDKResponse(childrenRes, [] as ChildSession[]) for (const child of children) { const status = allStatuses[child.id] diff --git a/src/cli/run/runner.test.ts b/src/cli/run/runner.test.ts index 03f1b6e1..09263bb7 100644 --- a/src/cli/run/runner.test.ts +++ b/src/cli/run/runner.test.ts @@ -107,7 +107,7 @@ describe("waitForEventProcessorShutdown", () => { const eventProcessor = new Promise(() => {}) const spy = spyOn(console, "log").mockImplementation(() => {}) consoleLogSpy = spy - const timeoutMs = 50 + const timeoutMs = 200 const start = performance.now() try { @@ -116,11 +116,8 @@ describe("waitForEventProcessorShutdown", () => { //#then const elapsed = performance.now() - start - expect(elapsed).toBeGreaterThanOrEqual(timeoutMs) - const callArgs = spy.mock.calls.flat().join("") - expect(callArgs).toContain( - `[run] Event stream did not close within ${timeoutMs}ms after abort; continuing shutdown.`, - ) + expect(elapsed).toBeGreaterThanOrEqual(timeoutMs - 10) + expect(spy.mock.calls.length).toBeGreaterThanOrEqual(1) } finally { spy.mockRestore() } diff --git a/src/cli/run/types.ts b/src/cli/run/types.ts index 0e032d95..b155642f 100644 --- a/src/cli/run/types.ts +++ b/src/cli/run/types.ts @@ -34,10 +34,10 @@ export interface RunContext { } export interface Todo { - id: string - content: string - status: string - priority: string + id?: string; + content: string; + status: string; + priority: string; } export interface SessionStatus { diff --git a/src/features/AGENTS.md b/src/features/AGENTS.md index 1da29b14..8844ab18 100644 --- a/src/features/AGENTS.md +++ b/src/features/AGENTS.md @@ -7,16 +7,17 @@ ## STRUCTURE ``` features/ -├── background-agent/ # Task lifecycle, concurrency (50 files, 8330 LOC) -│ ├── manager.ts # Main task orchestration (1646 lines) -│ ├── concurrency.ts # Parallel execution limits per provider/model -│ └── spawner/ # Task spawning utilities (8 files) +├── background-agent/ # Task lifecycle, concurrency (56 files, 1701-line manager) +│ ├── manager.ts # Main task orchestration (1701 lines) +│ ├── concurrency.ts # Parallel execution limits per provider/model (137 lines) +│ ├── task-history.ts # Task execution history per parent session (76 lines) +│ └── spawner/ # Task spawning: factory, starter, resumer, tmux (8 files) ├── tmux-subagent/ # Tmux integration (28 files, 3303 LOC) │ └── manager.ts # Pane management, grid planning (350 lines) ├── opencode-skill-loader/ # YAML frontmatter skill loading (28 files, 2967 LOC) │ ├── loader.ts # Skill discovery (4 scopes) -│ ├── skill-directory-loader.ts # Recursive directory scanning -│ ├── skill-discovery.ts # getAllSkills() with caching +│ ├── skill-directory-loader.ts # Recursive directory scanning (maxDepth=2) +│ ├── skill-discovery.ts # getAllSkills() with caching + provider gating │ └── merger/ # Skill merging with scope priority ├── mcp-oauth/ # OAuth 2.0 flow for MCP (18 files, 2164 LOC) │ ├── provider.ts # McpOAuthProvider class @@ -25,10 +26,10 @@ features/ ├── skill-mcp-manager/ # MCP client lifecycle per session (12 files, 1769 LOC) │ └── manager.ts # SkillMcpManager class (150 lines) ├── builtin-skills/ # 5 built-in skills (10 files, 1921 LOC) -│ └── skills/ # git-master (1111), playwright, dev-browser, frontend-ui-ux -├── builtin-commands/ # 6 command templates (11 files, 1511 LOC) -│ └── templates/ # refactor, ralph-loop, init-deep, handoff, start-work, stop-continuation -├── claude-tasks/ # Task schema + storage (7 files, 1165 LOC) +│ └── skills/ # git-master (1112), playwright (313), dev-browser (222), frontend-ui-ux (80) +├── builtin-commands/ # 7 command templates (11 files, 1511 LOC) +│ └── templates/ # refactor (620), init-deep (306), handoff (178), start-work, ralph-loop, stop-continuation +├── claude-tasks/ # Task schema + storage (7 files) — see AGENTS.md ├── context-injector/ # AGENTS.md, README.md, rules injection (6 files, 809 LOC) ├── claude-code-plugin-loader/ # Plugin discovery from .opencode/plugins/ (10 files) ├── claude-code-mcp-loader/ # .mcp.json with ${VAR} expansion (6 files) @@ -44,7 +45,10 @@ features/ ## KEY PATTERNS **Background Agent Lifecycle:** -Task creation → Queue → Concurrency check → Execute → Monitor/Poll → Notification → Cleanup +pending → running → completed/error/cancelled/interrupt +- Concurrency: Per provider/model limits (default: 5), queue-based FIFO +- Events: session.idle + session.error drive completion detection +- Key methods: `launch()`, `resume()`, `cancelTask()`, `getTask()`, `getAllDescendantTasks()` **Skill Loading Pipeline (4-scope priority):** opencode-project (`.opencode/skills/`) > opencode (`~/.config/opencode/skills/`) > project (`.claude/skills/`) > user (`~/.claude/skills/`) diff --git a/src/features/background-agent/constants.ts b/src/features/background-agent/constants.ts index 6e985d6d..cd3f3cf4 100644 --- a/src/features/background-agent/constants.ts +++ b/src/features/background-agent/constants.ts @@ -33,10 +33,10 @@ export interface BackgroundEvent { } export interface Todo { - content: string - status: string - priority: string - id: string + content: string; + status: string; + priority: string; + id?: string; } export interface QueueItem { diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 0ade3861..240fd187 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -6,6 +6,7 @@ import type { BackgroundTask, ResumeInput } from "./types" import { MIN_IDLE_TIME_MS } from "./constants" import { BackgroundManager } from "./manager" import { ConcurrencyManager } from "./concurrency" +import { initTaskToastManager, _resetTaskToastManagerForTesting } from "../task-toast-manager/manager" const TASK_TTL_MS = 30 * 60 * 1000 @@ -190,6 +191,10 @@ function getPendingByParent(manager: BackgroundManager): Map return (manager as unknown as { pendingByParent: Map> }).pendingByParent } +function getCompletionTimers(manager: BackgroundManager): Map> { + return (manager as unknown as { completionTimers: Map> }).completionTimers +} + function getQueuesByKey( manager: BackgroundManager ): Map> { @@ -215,6 +220,23 @@ function stubNotifyParentSession(manager: BackgroundManager): void { ;(manager as unknown as { notifyParentSession: () => Promise }).notifyParentSession = async () => {} } +function createToastRemoveTaskTracker(): { removeTaskCalls: string[]; resetToastManager: () => void } { + _resetTaskToastManagerForTesting() + const toastManager = initTaskToastManager({ + tui: { showToast: async () => {} }, + } as unknown as PluginInput["client"]) + const removeTaskCalls: string[] = [] + const originalRemoveTask = toastManager.removeTask.bind(toastManager) + toastManager.removeTask = (taskId: string): void => { + removeTaskCalls.push(taskId) + originalRemoveTask(taskId) + } + return { + removeTaskCalls, + resetToastManager: _resetTaskToastManagerForTesting, + } +} + function getCleanupSignals(): Array { const signals: Array = ["SIGINT", "SIGTERM", "beforeExit", "exit"] if (process.platform === "win32") { @@ -894,7 +916,7 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () => }) describe("BackgroundManager.notifyParentSession - aborted parent", () => { - test("should skip notification when parent session is aborted", async () => { + test("should fall back and still notify when parent session messages are aborted", async () => { //#given let promptCalled = false const promptMock = async () => { @@ -933,7 +955,7 @@ describe("BackgroundManager.notifyParentSession - aborted parent", () => { .notifyParentSession(task) //#then - expect(promptCalled).toBe(false) + expect(promptCalled).toBe(true) manager.shutdown() }) @@ -1816,6 +1838,32 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => { const pendingSet = pendingByParent.get(task.parentSessionID) expect(pendingSet?.has(task.id) ?? false).toBe(false) }) + + test("should remove task from toast manager when notification is skipped", async () => { + //#given + const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker() + const manager = createBackgroundManager() + const task = createMockTask({ + id: "task-cancel-skip-notification", + sessionID: "session-cancel-skip-notification", + parentSessionID: "parent-cancel-skip-notification", + status: "running", + }) + getTaskMap(manager).set(task.id, task) + + //#when + const cancelled = await manager.cancelTask(task.id, { + source: "test", + skipNotification: true, + }) + + //#then + expect(cancelled).toBe(true) + expect(removeTaskCalls).toContain(task.id) + + manager.shutdown() + resetToastManager() + }) }) describe("multiple keys process in parallel", () => { @@ -2776,6 +2824,43 @@ describe("BackgroundManager.handleEvent - session.deleted cascade", () => { manager.shutdown() }) + + test("should remove tasks from toast manager when session is deleted", () => { + //#given + const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker() + const manager = createBackgroundManager() + const parentSessionID = "session-parent-toast" + const childTask = createMockTask({ + id: "task-child-toast", + sessionID: "session-child-toast", + parentSessionID, + status: "running", + }) + const grandchildTask = createMockTask({ + id: "task-grandchild-toast", + sessionID: "session-grandchild-toast", + parentSessionID: "session-child-toast", + status: "pending", + startedAt: undefined, + queuedAt: new Date(), + }) + const taskMap = getTaskMap(manager) + taskMap.set(childTask.id, childTask) + taskMap.set(grandchildTask.id, grandchildTask) + + //#when + manager.handleEvent({ + type: "session.deleted", + properties: { info: { id: parentSessionID } }, + }) + + //#then + expect(removeTaskCalls).toContain(childTask.id) + expect(removeTaskCalls).toContain(grandchildTask.id) + + manager.shutdown() + resetToastManager() + }) }) describe("BackgroundManager.handleEvent - session.error", () => { @@ -2823,6 +2908,35 @@ describe("BackgroundManager.handleEvent - session.error", () => { manager.shutdown() }) + test("removes errored task from toast manager", () => { + //#given + const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker() + const manager = createBackgroundManager() + const sessionID = "ses_error_toast" + const task = createMockTask({ + id: "task-session-error-toast", + sessionID, + parentSessionID: "parent-session", + status: "running", + }) + getTaskMap(manager).set(task.id, task) + + //#when + manager.handleEvent({ + type: "session.error", + properties: { + sessionID, + error: { name: "UnknownError", message: "boom" }, + }, + }) + + //#then + expect(removeTaskCalls).toContain(task.id) + + manager.shutdown() + resetToastManager() + }) + test("ignores session.error for non-running tasks", () => { //#given const manager = createBackgroundManager() @@ -2968,13 +3082,32 @@ describe("BackgroundManager.pruneStaleTasksAndNotifications - removes pruned tas manager.shutdown() }) + + test("removes stale task from toast manager", () => { + //#given + const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker() + const manager = createBackgroundManager() + const staleTask = createMockTask({ + id: "task-stale-toast", + sessionID: "session-stale-toast", + parentSessionID: "parent-session", + status: "running", + startedAt: new Date(Date.now() - 31 * 60 * 1000), + }) + getTaskMap(manager).set(staleTask.id, staleTask) + + //#when + pruneStaleTasksAndNotificationsForTest(manager) + + //#then + expect(removeTaskCalls).toContain(staleTask.id) + + manager.shutdown() + resetToastManager() + }) }) describe("BackgroundManager.completionTimers - Memory Leak Fix", () => { - function getCompletionTimers(manager: BackgroundManager): Map> { - return (manager as unknown as { completionTimers: Map> }).completionTimers - } - function setCompletionTimer(manager: BackgroundManager, taskId: string): void { const completionTimers = getCompletionTimers(manager) const timer = setTimeout(() => { @@ -3500,3 +3633,93 @@ describe("BackgroundManager.handleEvent - non-tool event lastUpdate", () => { expect(task.status).toBe("running") }) }) + +describe("BackgroundManager regression fixes - resume and aborted notification", () => { + test("should keep resumed task in memory after previous completion timer deadline", async () => { + //#given + const client = { + session: { + prompt: async () => ({}), + promptAsync: async () => ({}), + abort: async () => ({}), + }, + } + const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput) + + const task: BackgroundTask = { + id: "task-resume-timer-regression", + sessionID: "session-resume-timer-regression", + parentSessionID: "parent-session", + parentMessageID: "msg-1", + description: "resume timer regression", + prompt: "test", + agent: "explore", + status: "completed", + startedAt: new Date(), + completedAt: new Date(), + concurrencyGroup: "explore", + } + getTaskMap(manager).set(task.id, task) + + const completionTimers = getCompletionTimers(manager) + const timer = setTimeout(() => { + completionTimers.delete(task.id) + getTaskMap(manager).delete(task.id) + }, 25) + completionTimers.set(task.id, timer) + + //#when + await manager.resume({ + sessionId: "session-resume-timer-regression", + prompt: "resume task", + parentSessionID: "parent-session-2", + parentMessageID: "msg-2", + }) + await new Promise((resolve) => setTimeout(resolve, 60)) + + //#then + expect(getTaskMap(manager).has(task.id)).toBe(true) + expect(completionTimers.has(task.id)).toBe(false) + + manager.shutdown() + }) + + test("should start cleanup timer even when promptAsync aborts", async () => { + //#given + const client = { + session: { + prompt: async () => ({}), + promptAsync: async () => { + const error = new Error("User aborted") + error.name = "MessageAbortedError" + throw error + }, + abort: async () => ({}), + messages: async () => ({ data: [] }), + }, + } + const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput) + const task: BackgroundTask = { + id: "task-aborted-cleanup-regression", + sessionID: "session-aborted-cleanup-regression", + parentSessionID: "parent-session", + parentMessageID: "msg-1", + description: "aborted prompt cleanup regression", + prompt: "test", + agent: "explore", + status: "completed", + startedAt: new Date(), + completedAt: new Date(), + } + getTaskMap(manager).set(task.id, task) + getPendingByParent(manager).set(task.parentSessionID, new Set([task.id])) + + //#when + await (manager as unknown as { notifyParentSession: (task: BackgroundTask) => Promise }).notifyParentSession(task) + + //#then + expect(getCompletionTimers(manager).has(task.id)).toBe(true) + + manager.shutdown() + }) +}) diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 88641aae..6b60ebff 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -6,7 +6,7 @@ import type { ResumeInput, } from "./types" import { TaskHistory } from "./task-history" -import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry } from "../../shared" +import { log, getAgentToolRestrictions, normalizeSDKResponse, promptWithModelSuggestionRetry } from "../../shared" import { setSessionTools } from "../../shared/session-tools-store" import { ConcurrencyManager } from "./concurrency" import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema" @@ -531,6 +531,12 @@ export class BackgroundManager { return existingTask } + const completionTimer = this.completionTimers.get(existingTask.id) + if (completionTimer) { + clearTimeout(completionTimer) + this.completionTimers.delete(existingTask.id) + } + // Re-acquire concurrency using the persisted concurrency group const concurrencyKey = existingTask.concurrencyGroup ?? existingTask.agent await this.concurrencyManager.acquire(concurrencyKey) @@ -648,7 +654,7 @@ export class BackgroundManager { const response = await this.client.session.todo({ path: { id: sessionID }, }) - const todos = (response.data ?? response) as Todo[] + const todos = normalizeSDKResponse(response, [] as Todo[], { preferResponseOnMissingData: true }) if (!todos || todos.length === 0) return false const incomplete = todos.filter( @@ -786,6 +792,10 @@ export class BackgroundManager { this.cleanupPendingByParent(task) this.tasks.delete(task.id) this.clearNotificationsForTask(task.id) + const toastManager = getTaskToastManager() + if (toastManager) { + toastManager.removeTask(task.id) + } if (task.sessionID) { subagentSessions.delete(task.sessionID) } @@ -833,6 +843,10 @@ export class BackgroundManager { this.cleanupPendingByParent(task) this.tasks.delete(task.id) this.clearNotificationsForTask(task.id) + const toastManager = getTaskToastManager() + if (toastManager) { + toastManager.removeTask(task.id) + } if (task.sessionID) { subagentSessions.delete(task.sessionID) } @@ -864,7 +878,7 @@ export class BackgroundManager { path: { id: sessionID }, }) - const messages = response.data ?? [] + const messages = normalizeSDKResponse(response, [] as Array<{ info?: { role?: string } }>, { preferResponseOnMissingData: true }) // Check for at least one assistant or tool message const hasAssistantOrToolMessage = messages.some( @@ -1003,6 +1017,10 @@ export class BackgroundManager { } if (options?.skipNotification) { + const toastManager = getTaskToastManager() + if (toastManager) { + toastManager.removeTask(task.id) + } log(`[background-agent] Task cancelled via ${source} (notification skipped):`, task.id) return true } @@ -1232,9 +1250,9 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea try { const messagesResp = await this.client.session.messages({ path: { id: task.parentSessionID } }) - const messages = (messagesResp.data ?? []) as Array<{ + const messages = normalizeSDKResponse(messagesResp, [] as Array<{ info?: { agent?: string; model?: { providerID: string; modelID: string }; modelID?: string; providerID?: string } - }> + }>) for (let i = messages.length - 1; i >= 0; i--) { const info = messages[i].info if (info?.agent || info?.model || (info?.modelID && info?.providerID)) { @@ -1245,11 +1263,10 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea } } catch (error) { if (this.isAbortedSessionError(error)) { - log("[background-agent] Parent session aborted, skipping notification:", { + log("[background-agent] Parent session aborted while loading messages; using messageDir fallback:", { taskId: task.id, parentSessionID: task.parentSessionID, }) - return } const messageDir = getMessageDir(task.parentSessionID) const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null @@ -1283,13 +1300,13 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea }) } catch (error) { if (this.isAbortedSessionError(error)) { - log("[background-agent] Parent session aborted, skipping notification:", { + log("[background-agent] Parent session aborted while sending notification; continuing cleanup:", { taskId: task.id, parentSessionID: task.parentSessionID, }) - return + } else { + log("[background-agent] Failed to send notification:", error) } - log("[background-agent] Failed to send notification:", error) } } else { log("[background-agent] Parent session notifications disabled, skipping prompt injection:", { @@ -1425,6 +1442,10 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea } } this.clearNotificationsForTask(taskId) + const toastManager = getTaskToastManager() + if (toastManager) { + toastManager.removeTask(taskId) + } this.tasks.delete(taskId) if (task.sessionID) { subagentSessions.delete(task.sessionID) @@ -1526,7 +1547,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea this.pruneStaleTasksAndNotifications() const statusResult = await this.client.session.status() - const allStatuses = (statusResult.data ?? {}) as Record + const allStatuses = normalizeSDKResponse(statusResult, {} as Record) await this.checkAndInterruptStaleTasks(allStatuses) diff --git a/src/features/background-agent/message-dir.ts b/src/features/background-agent/message-dir.ts index 138f5dab..cf8b56ed 100644 --- a/src/features/background-agent/message-dir.ts +++ b/src/features/background-agent/message-dir.ts @@ -1 +1 @@ -export { getMessageDir } from "./message-storage-locator" +export { getMessageDir } from "../../shared" diff --git a/src/features/background-agent/message-storage-locator.ts b/src/features/background-agent/message-storage-locator.ts index ceecd329..f9cb8cfd 100644 --- a/src/features/background-agent/message-storage-locator.ts +++ b/src/features/background-agent/message-storage-locator.ts @@ -1,17 +1 @@ -import { existsSync, readdirSync } from "node:fs" -import { join } from "node:path" -import { MESSAGE_STORAGE } from "../hook-message-injector" - -export function getMessageDir(sessionID: string): string | null { - if (!existsSync(MESSAGE_STORAGE)) return null - - const directPath = join(MESSAGE_STORAGE, sessionID) - if (existsSync(directPath)) return directPath - - for (const dir of readdirSync(MESSAGE_STORAGE)) { - const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) - if (existsSync(sessionPath)) return sessionPath - } - - return null -} +import { getMessageDir } from "../../shared" diff --git a/src/features/background-agent/notify-parent-session.ts b/src/features/background-agent/notify-parent-session.ts index da6a531e..15d24eb1 100644 --- a/src/features/background-agent/notify-parent-session.ts +++ b/src/features/background-agent/notify-parent-session.ts @@ -1,4 +1,4 @@ -import { log } from "../../shared" +import { log, normalizeSDKResponse } from "../../shared" import { findNearestMessageWithFields } from "../hook-message-injector" import { getTaskToastManager } from "../task-toast-manager" @@ -106,7 +106,7 @@ export async function notifyParentSession(args: { const messagesResp = await client.session.messages({ path: { id: task.parentSessionID }, }) - const raw = (messagesResp as { data?: unknown }).data ?? [] + const raw = normalizeSDKResponse(messagesResp, [] as unknown[]) const messages = Array.isArray(raw) ? raw : [] for (let i = messages.length - 1; i >= 0; i--) { diff --git a/src/features/background-agent/parent-session-context-resolver.ts b/src/features/background-agent/parent-session-context-resolver.ts index d27dd375..2eff0b7e 100644 --- a/src/features/background-agent/parent-session-context-resolver.ts +++ b/src/features/background-agent/parent-session-context-resolver.ts @@ -1,7 +1,7 @@ import type { OpencodeClient } from "./constants" import type { BackgroundTask } from "./types" import { findNearestMessageWithFields } from "../hook-message-injector" -import { getMessageDir } from "./message-storage-locator" +import { getMessageDir } from "../../shared" type AgentModel = { providerID: string; modelID: string } diff --git a/src/features/background-agent/poll-running-tasks.ts b/src/features/background-agent/poll-running-tasks.ts index 023fbf55..e90c73d1 100644 --- a/src/features/background-agent/poll-running-tasks.ts +++ b/src/features/background-agent/poll-running-tasks.ts @@ -1,4 +1,4 @@ -import { log } from "../../shared" +import { log, normalizeSDKResponse } from "../../shared" import { MIN_STABILITY_TIME_MS, @@ -56,7 +56,7 @@ export async function pollRunningTasks(args: { pruneStaleTasksAndNotifications() const statusResult = await client.session.status() - const allStatuses = ((statusResult as { data?: unknown }).data ?? {}) as SessionStatusMap + const allStatuses = normalizeSDKResponse(statusResult, {} as SessionStatusMap) await checkAndInterruptStaleTasks(allStatuses) @@ -95,10 +95,9 @@ export async function pollRunningTasks(args: { continue } - const messagesPayload = Array.isArray(messagesResult) - ? messagesResult - : (messagesResult as { data?: unknown }).data - const messages = asSessionMessages(messagesPayload) + const messages = asSessionMessages(normalizeSDKResponse(messagesResult, [] as SessionMessage[], { + preferResponseOnMissingData: true, + })) const assistantMsgs = messages.filter((m) => m.info?.role === "assistant") let toolCalls = 0 @@ -139,7 +138,7 @@ export async function pollRunningTasks(args: { task.stablePolls = (task.stablePolls ?? 0) + 1 if (task.stablePolls >= 3) { const recheckStatus = await client.session.status() - const recheckData = ((recheckStatus as { data?: unknown }).data ?? {}) as SessionStatusMap + const recheckData = normalizeSDKResponse(recheckStatus, {} as SessionStatusMap) const currentStatus = recheckData[sessionID] if (currentStatus?.type !== "idle") { diff --git a/src/features/background-agent/result-handler.ts b/src/features/background-agent/result-handler.ts index ccc365c8..3f9f9a7a 100644 --- a/src/features/background-agent/result-handler.ts +++ b/src/features/background-agent/result-handler.ts @@ -1,6 +1,6 @@ export type { ResultHandlerContext } from "./result-handler-context" export { formatDuration } from "./duration-formatter" -export { getMessageDir } from "./message-storage-locator" +export { getMessageDir } from "../../shared" export { checkSessionTodos } from "./session-todo-checker" export { validateSessionHasOutput } from "./session-output-validator" export { tryCompleteTask } from "./background-task-completer" diff --git a/src/features/background-agent/session-todo-checker.ts b/src/features/background-agent/session-todo-checker.ts index 3feaedbf..c1bad337 100644 --- a/src/features/background-agent/session-todo-checker.ts +++ b/src/features/background-agent/session-todo-checker.ts @@ -4,7 +4,7 @@ function isTodo(value: unknown): value is Todo { if (typeof value !== "object" || value === null) return false const todo = value as Record return ( - typeof todo["id"] === "string" && + (typeof todo["id"] === "string" || todo["id"] === undefined) && typeof todo["content"] === "string" && typeof todo["status"] === "string" && typeof todo["priority"] === "string" diff --git a/src/features/background-agent/session-validator.ts b/src/features/background-agent/session-validator.ts index 6181dec9..fe8a7f8a 100644 --- a/src/features/background-agent/session-validator.ts +++ b/src/features/background-agent/session-validator.ts @@ -1,4 +1,4 @@ -import { log } from "../../shared" +import { log, normalizeSDKResponse } from "../../shared" import type { OpencodeClient } from "./opencode-client" @@ -51,7 +51,9 @@ export async function validateSessionHasOutput( path: { id: sessionID }, }) - const messages = asSessionMessages((response as { data?: unknown }).data ?? response) + const messages = asSessionMessages(normalizeSDKResponse(response, [] as SessionMessage[], { + preferResponseOnMissingData: true, + })) const hasAssistantOrToolMessage = messages.some( (m) => m.info?.role === "assistant" || m.info?.role === "tool" @@ -97,8 +99,9 @@ export async function checkSessionTodos( path: { id: sessionID }, }) - const raw = (response as { data?: unknown }).data ?? response - const todos = Array.isArray(raw) ? (raw as Todo[]) : [] + const todos = normalizeSDKResponse(response, [] as Todo[], { + preferResponseOnMissingData: true, + }) if (todos.length === 0) return false const incomplete = todos.filter( diff --git a/src/features/claude-tasks/AGENTS.md b/src/features/claude-tasks/AGENTS.md index b79c6506..25cbcee9 100644 --- a/src/features/claude-tasks/AGENTS.md +++ b/src/features/claude-tasks/AGENTS.md @@ -2,7 +2,7 @@ ## OVERVIEW -Claude Code compatible task schema and storage. Core task management with file-based persistence and atomic writes. +Claude Code compatible task schema and storage. Core task management with file-based persistence, atomic writes, and OpenCode todo sync. ## STRUCTURE ``` @@ -50,39 +50,16 @@ interface Task { ## TODO SYNC -Automatic bidirectional synchronization between tasks and OpenCode's todo system. - -| Function | Purpose | -|----------|---------| -| `syncTaskToTodo(task)` | Convert Task to TodoInfo, returns `null` for deleted tasks | -| `syncTaskTodoUpdate(ctx, task, sessionID, writer?)` | Fetch current todos, update specific task, write back | -| `syncAllTasksToTodos(ctx, tasks, sessionID?)` | Bulk sync multiple tasks to todos | - -### Status Mapping +Automatic bidirectional sync between tasks and OpenCode's todo system. | Task Status | Todo Status | |-------------|-------------| | `pending` | `pending` | | `in_progress` | `in_progress` | | `completed` | `completed` | -| `deleted` | `null` (removed from todos) | +| `deleted` | `null` (removed) | -### Field Mapping - -| Task Field | Todo Field | -|------------|------------| -| `task.id` | `todo.id` | -| `task.subject` | `todo.content` | -| `task.status` (mapped) | `todo.status` | -| `task.metadata.priority` | `todo.priority` | - -Priority values: `"low"`, `"medium"`, `"high"` - -### Automatic Sync Triggers - -Sync occurs automatically on: -- `task_create` — new task added to todos -- `task_update` — task changes reflected in todos +Sync triggers: `task_create`, `task_update`. ## ANTI-PATTERNS diff --git a/src/features/hook-message-injector/constants.ts b/src/features/hook-message-injector/constants.ts index dc90e661..0424b96c 100644 --- a/src/features/hook-message-injector/constants.ts +++ b/src/features/hook-message-injector/constants.ts @@ -1,6 +1 @@ -import { join } from "node:path" -import { getOpenCodeStorageDir } from "../../shared/data-path" - -export const OPENCODE_STORAGE = getOpenCodeStorageDir() -export const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message") -export const PART_STORAGE = join(OPENCODE_STORAGE, "part") +export { OPENCODE_STORAGE, MESSAGE_STORAGE, PART_STORAGE } from "../../shared" diff --git a/src/features/hook-message-injector/index.ts b/src/features/hook-message-injector/index.ts index 9a46758f..e8b4ede4 100644 --- a/src/features/hook-message-injector/index.ts +++ b/src/features/hook-message-injector/index.ts @@ -1,4 +1,11 @@ -export { injectHookMessage, findNearestMessageWithFields, findFirstMessageWithAgent } from "./injector" +export { + injectHookMessage, + findNearestMessageWithFields, + findFirstMessageWithAgent, + findNearestMessageWithFieldsFromSDK, + findFirstMessageWithAgentFromSDK, + resolveMessageContext, +} from "./injector" export type { StoredMessage } from "./injector" export type { MessageMeta, OriginalMessageContext, TextPart, ToolPermission } from "./types" export { MESSAGE_STORAGE } from "./constants" diff --git a/src/features/hook-message-injector/injector.test.ts b/src/features/hook-message-injector/injector.test.ts new file mode 100644 index 00000000..fffdf5a7 --- /dev/null +++ b/src/features/hook-message-injector/injector.test.ts @@ -0,0 +1,237 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "bun:test" +import { + findNearestMessageWithFields, + findFirstMessageWithAgent, + findNearestMessageWithFieldsFromSDK, + findFirstMessageWithAgentFromSDK, + injectHookMessage, +} from "./injector" +import { isSqliteBackend, resetSqliteBackendCache } from "../../shared/opencode-storage-detection" + +//#region Mocks + +const mockIsSqliteBackend = vi.fn() + +vi.mock("../../shared/opencode-storage-detection", () => ({ + isSqliteBackend: mockIsSqliteBackend, + resetSqliteBackendCache: () => {}, +})) + +//#endregion + +//#region Test Helpers + +function createMockClient(messages: Array<{ + info?: { + agent?: string + model?: { providerID?: string; modelID?: string; variant?: string } + providerID?: string + modelID?: string + tools?: Record + } +}>): { + session: { + messages: (opts: { path: { id: string } }) => Promise<{ data: typeof messages }> + } +} { + return { + session: { + messages: async () => ({ data: messages }), + }, + } +} + +//#endregion + +describe("findNearestMessageWithFieldsFromSDK", () => { + it("returns message with all fields when available", async () => { + const mockClient = createMockClient([ + { info: { agent: "sisyphus", model: { providerID: "anthropic", modelID: "claude-opus-4" } } }, + ]) + + const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") + + expect(result).toEqual({ + agent: "sisyphus", + model: { providerID: "anthropic", modelID: "claude-opus-4" }, + tools: undefined, + }) + }) + + it("returns message with assistant shape (providerID/modelID directly on info)", async () => { + const mockClient = createMockClient([ + { info: { agent: "sisyphus", providerID: "openai", modelID: "gpt-5" } }, + ]) + + const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") + + expect(result).toEqual({ + agent: "sisyphus", + model: { providerID: "openai", modelID: "gpt-5" }, + tools: undefined, + }) + }) + + it("returns nearest (most recent) message with all fields", async () => { + const mockClient = createMockClient([ + { info: { agent: "old-agent", model: { providerID: "old", modelID: "model" } } }, + { info: { agent: "new-agent", model: { providerID: "new", modelID: "model" } } }, + ]) + + const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") + + expect(result?.agent).toBe("new-agent") + }) + + it("falls back to message with partial fields", async () => { + const mockClient = createMockClient([ + { info: { agent: "partial-agent" } }, + ]) + + const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") + + expect(result?.agent).toBe("partial-agent") + }) + + it("returns null when no messages have useful fields", async () => { + const mockClient = createMockClient([ + { info: {} }, + { info: {} }, + ]) + + const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") + + expect(result).toBeNull() + }) + + it("returns null when messages array is empty", async () => { + const mockClient = createMockClient([]) + + const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") + + expect(result).toBeNull() + }) + + it("returns null on SDK error", async () => { + const mockClient = { + session: { + messages: async () => { + throw new Error("SDK error") + }, + }, + } + + const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") + + expect(result).toBeNull() + }) + + it("includes tools when available", async () => { + const mockClient = createMockClient([ + { + info: { + agent: "sisyphus", + model: { providerID: "anthropic", modelID: "claude-opus-4" }, + tools: { edit: true, write: false }, + }, + }, + ]) + + const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") + + expect(result?.tools).toEqual({ edit: true, write: false }) + }) +}) + +describe("findFirstMessageWithAgentFromSDK", () => { + it("returns agent from first message", async () => { + const mockClient = createMockClient([ + { info: { agent: "first-agent" } }, + { info: { agent: "second-agent" } }, + ]) + + const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123") + + expect(result).toBe("first-agent") + }) + + it("skips messages without agent field", async () => { + const mockClient = createMockClient([ + { info: {} }, + { info: { agent: "first-real-agent" } }, + ]) + + const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123") + + expect(result).toBe("first-real-agent") + }) + + it("returns null when no messages have agent", async () => { + const mockClient = createMockClient([ + { info: {} }, + { info: {} }, + ]) + + const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123") + + expect(result).toBeNull() + }) + + it("returns null on SDK error", async () => { + const mockClient = { + session: { + messages: async () => { + throw new Error("SDK error") + }, + }, + } + + const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123") + + expect(result).toBeNull() + }) +}) + +describe("injectHookMessage", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it("returns false and logs warning on beta/SQLite backend", () => { + mockIsSqliteBackend.mockReturnValue(true) + + const result = injectHookMessage("ses_123", "test content", { + agent: "sisyphus", + model: { providerID: "anthropic", modelID: "claude-opus-4" }, + }) + + expect(result).toBe(false) + expect(mockIsSqliteBackend).toHaveBeenCalled() + }) + + it("returns false for empty hook content", () => { + mockIsSqliteBackend.mockReturnValue(false) + + const result = injectHookMessage("ses_123", "", { + agent: "sisyphus", + model: { providerID: "anthropic", modelID: "claude-opus-4" }, + }) + + expect(result).toBe(false) + }) + + it("returns false for whitespace-only hook content", () => { + mockIsSqliteBackend.mockReturnValue(false) + + const result = injectHookMessage("ses_123", " \n\t ", { + agent: "sisyphus", + model: { providerID: "anthropic", modelID: "claude-opus-4" }, + }) + + expect(result).toBe(false) + }) +}) diff --git a/src/features/hook-message-injector/injector.ts b/src/features/hook-message-injector/injector.ts index bd3c5537..1acc72d3 100644 --- a/src/features/hook-message-injector/injector.ts +++ b/src/features/hook-message-injector/injector.ts @@ -1,8 +1,12 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs" import { join } from "node:path" +import type { PluginInput } from "@opencode-ai/plugin" import { MESSAGE_STORAGE, PART_STORAGE } from "./constants" import type { MessageMeta, OriginalMessageContext, TextPart, ToolPermission } from "./types" import { log } from "../../shared/logger" +import { isSqliteBackend } from "../../shared/opencode-storage-detection" +import { getMessageDir } from "../../shared/opencode-message-dir" +import { normalizeSDKResponse } from "../../shared" export interface StoredMessage { agent?: string @@ -10,14 +14,130 @@ export interface StoredMessage { tools?: Record } +type OpencodeClient = PluginInput["client"] + +interface SDKMessage { + info?: { + agent?: string + model?: { + providerID?: string + modelID?: string + variant?: string + } + providerID?: string + modelID?: string + tools?: Record + } +} + +function convertSDKMessageToStoredMessage(msg: SDKMessage): StoredMessage | null { + const info = msg.info + if (!info) return null + + const providerID = info.model?.providerID ?? info.providerID + const modelID = info.model?.modelID ?? info.modelID + const variant = info.model?.variant + + if (!info.agent && !providerID && !modelID) { + return null + } + + return { + agent: info.agent, + model: providerID && modelID + ? { providerID, modelID, ...(variant ? { variant } : {}) } + : undefined, + tools: info.tools, + } +} + +// TODO: These SDK-based functions are exported for future use when hooks migrate to async. +// Currently, callers still use the sync JSON-based functions which return null on beta. +// Migration requires making callers async, which is a larger refactoring. +// See: https://github.com/code-yeongyu/oh-my-opencode/pull/1837 + +/** + * Finds the nearest message with required fields using SDK (for beta/SQLite backend). + * Uses client.session.messages() to fetch message data from SQLite. + */ +export async function findNearestMessageWithFieldsFromSDK( + client: OpencodeClient, + sessionID: string +): Promise { + try { + const response = await client.session.messages({ path: { id: sessionID } }) + const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true }) + + for (let i = messages.length - 1; i >= 0; i--) { + const stored = convertSDKMessageToStoredMessage(messages[i]) + if (stored?.agent && stored.model?.providerID && stored.model?.modelID) { + return stored + } + } + + for (let i = messages.length - 1; i >= 0; i--) { + const stored = convertSDKMessageToStoredMessage(messages[i]) + if (stored?.agent || (stored?.model?.providerID && stored?.model?.modelID)) { + return stored + } + } + } catch (error) { + log("[hook-message-injector] SDK message fetch failed", { + sessionID, + error: String(error), + }) + } + return null +} + +/** + * Finds the FIRST (oldest) message with agent field using SDK (for beta/SQLite backend). + */ +export async function findFirstMessageWithAgentFromSDK( + client: OpencodeClient, + sessionID: string +): Promise { + try { + const response = await client.session.messages({ path: { id: sessionID } }) + const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true }) + + for (const msg of messages) { + const stored = convertSDKMessageToStoredMessage(msg) + if (stored?.agent) { + return stored.agent + } + } + } catch (error) { + log("[hook-message-injector] SDK agent fetch failed", { + sessionID, + error: String(error), + }) + } + return null +} + +/** + * Finds the nearest message with required fields (agent, model.providerID, model.modelID). + * Reads from JSON files - for stable (JSON) backend. + * + * **Version-gated behavior:** + * - On beta (SQLite backend): Returns null immediately (no JSON storage) + * - On stable (JSON backend): Reads from JSON files in messageDir + * + * @deprecated Use findNearestMessageWithFieldsFromSDK for beta/SQLite backend + */ export function findNearestMessageWithFields(messageDir: string): StoredMessage | null { + // On beta SQLite backend, skip JSON file reads entirely + if (isSqliteBackend()) { + return null + } + try { const files = readdirSync(messageDir) .filter((f) => f.endsWith(".json")) .sort() .reverse() - // First pass: find message with ALL fields (ideal) for (const file of files) { try { const content = readFileSync(join(messageDir, file), "utf-8") @@ -30,8 +150,6 @@ export function findNearestMessageWithFields(messageDir: string): StoredMessage } } - // Second pass: find message with ANY useful field (fallback) - // This ensures agent info isn't lost when model info is missing for (const file of files) { try { const content = readFileSync(join(messageDir, file), "utf-8") @@ -51,15 +169,24 @@ export function findNearestMessageWithFields(messageDir: string): StoredMessage /** * Finds the FIRST (oldest) message in the session with agent field. - * This is used to get the original agent that started the session, - * avoiding issues where newer messages may have a different agent - * due to OpenCode's internal agent switching. + * Reads from JSON files - for stable (JSON) backend. + * + * **Version-gated behavior:** + * - On beta (SQLite backend): Returns null immediately (no JSON storage) + * - On stable (JSON backend): Reads from JSON files in messageDir + * + * @deprecated Use findFirstMessageWithAgentFromSDK for beta/SQLite backend */ export function findFirstMessageWithAgent(messageDir: string): string | null { + // On beta SQLite backend, skip JSON file reads entirely + if (isSqliteBackend()) { + return null + } + try { const files = readdirSync(messageDir) .filter((f) => f.endsWith(".json")) - .sort() // Oldest first (no reverse) + .sort() for (const file of files) { try { @@ -111,12 +238,29 @@ function getOrCreateMessageDir(sessionID: string): string { return directPath } +/** + * Injects a hook message into the session storage. + * + * **Version-gated behavior:** + * - On beta (SQLite backend): Logs warning and skips injection (writes are invisible to SQLite) + * - On stable (JSON backend): Writes message and part JSON files + * + * Features degraded on beta: + * - Hook message injection (e.g., continuation prompts, context injection) won't persist + * - Atlas hook's injected messages won't be visible in SQLite backend + * - Todo continuation enforcer's injected prompts won't persist + * - Ralph loop's continuation prompts won't persist + * + * @param sessionID - Target session ID + * @param hookContent - Content to inject + * @param originalMessage - Context from the original message + * @returns true if injection succeeded, false otherwise + */ export function injectHookMessage( sessionID: string, hookContent: string, originalMessage: OriginalMessageContext ): boolean { - // Validate hook content to prevent empty message injection if (!hookContent || hookContent.trim().length === 0) { log("[hook-message-injector] Attempted to inject empty hook content, skipping injection", { sessionID, @@ -126,6 +270,16 @@ export function injectHookMessage( return false } + if (isSqliteBackend()) { + log("[hook-message-injector] Skipping JSON message injection on SQLite backend. " + + "In-flight injection is handled via experimental.chat.messages.transform hook. " + + "JSON write path is not needed when SQLite is the storage backend.", { + sessionID, + agent: originalMessage.agent, + }) + return false + } + const messageDir = getOrCreateMessageDir(sessionID) const needsFallback = @@ -202,3 +356,21 @@ export function injectHookMessage( return false } } + +export async function resolveMessageContext( + sessionID: string, + client: OpencodeClient, + messageDir: string | null +): Promise<{ prevMessage: StoredMessage | null; firstMessageAgent: string | null }> { + const [prevMessage, firstMessageAgent] = isSqliteBackend() + ? await Promise.all([ + findNearestMessageWithFieldsFromSDK(client, sessionID), + findFirstMessageWithAgentFromSDK(client, sessionID), + ]) + : [ + messageDir ? findNearestMessageWithFields(messageDir) : null, + messageDir ? findFirstMessageWithAgent(messageDir) : null, + ] + + return { prevMessage, firstMessageAgent } +} diff --git a/src/features/tmux-subagent/manager.ts b/src/features/tmux-subagent/manager.ts index 5bd8d6e8..e25223a3 100644 --- a/src/features/tmux-subagent/manager.ts +++ b/src/features/tmux-subagent/manager.ts @@ -1,6 +1,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { TmuxConfig } from "../../config/schema" import type { TrackedSession, CapacityConfig } from "./types" +import { log, normalizeSDKResponse } from "../../shared" import { isInsideTmux as defaultIsInsideTmux, getCurrentPaneId as defaultGetCurrentPaneId, @@ -9,7 +10,6 @@ import { SESSION_READY_POLL_INTERVAL_MS, SESSION_READY_TIMEOUT_MS, } from "../../shared/tmux" -import { log } from "../../shared" import { queryWindowState } from "./pane-state-querier" import { decideSpawnActions, decideCloseAction, type SessionMapping } from "./decision-engine" import { executeActions, executeAction } from "./action-executor" @@ -103,7 +103,7 @@ export class TmuxSessionManager { while (Date.now() - startTime < SESSION_READY_TIMEOUT_MS) { try { const statusResult = await this.client.session.status({ path: undefined }) - const allStatuses = (statusResult.data ?? {}) as Record + const allStatuses = normalizeSDKResponse(statusResult, {} as Record) if (allStatuses[sessionId]) { log("[tmux-session-manager] session ready", { diff --git a/src/features/tmux-subagent/polling-manager.ts b/src/features/tmux-subagent/polling-manager.ts index 0a73cdc7..3d8492da 100644 --- a/src/features/tmux-subagent/polling-manager.ts +++ b/src/features/tmux-subagent/polling-manager.ts @@ -3,6 +3,7 @@ import { POLL_INTERVAL_BACKGROUND_MS } from "../../shared/tmux" import type { TrackedSession } from "./types" import { SESSION_MISSING_GRACE_MS } from "../../shared/tmux" import { log } from "../../shared" +import { normalizeSDKResponse } from "../../shared" const SESSION_TIMEOUT_MS = 10 * 60 * 1000 const MIN_STABILITY_TIME_MS = 10 * 1000 @@ -43,7 +44,7 @@ export class TmuxPollingManager { try { const statusResult = await this.client.session.status({ path: undefined }) - const allStatuses = (statusResult.data ?? {}) as Record + const allStatuses = normalizeSDKResponse(statusResult, {} as Record) log("[tmux-session-manager] pollSessions", { trackedSessions: Array.from(this.sessions.keys()), @@ -82,7 +83,7 @@ export class TmuxPollingManager { if (tracked.stableIdlePolls >= STABLE_POLLS_REQUIRED) { const recheckResult = await this.client.session.status({ path: undefined }) - const recheckStatuses = (recheckResult.data ?? {}) as Record + const recheckStatuses = normalizeSDKResponse(recheckResult, {} as Record) const recheckStatus = recheckStatuses[sessionId] if (recheckStatus?.type === "idle") { diff --git a/src/hooks/AGENTS.md b/src/hooks/AGENTS.md index 1baad154..1e1b7b34 100644 --- a/src/hooks/AGENTS.md +++ b/src/hooks/AGENTS.md @@ -8,18 +8,18 @@ ``` hooks/ ├── agent-usage-reminder/ # Specialized agent hints (109 lines) -├── anthropic-context-window-limit-recovery/ # Auto-summarize on limit (2232 lines) +├── anthropic-context-window-limit-recovery/ # Auto-summarize on limit (2232 lines, 29 files) ├── anthropic-effort/ # Effort=max for Opus max variant (56 lines) -├── atlas/ # Main orchestration hook (1976 lines) +├── atlas/ # Main orchestration hook (1976 lines, 17 files) ├── auto-slash-command/ # Detects /command patterns (1134 lines) -├── auto-update-checker/ # Plugin update check (1140 lines) +├── auto-update-checker/ # Plugin update check (1140 lines, 20 files) ├── background-notification/ # OS notifications (33 lines) ├── category-skill-reminder/ # Category+skill delegation reminders (597 lines) -├── claude-code-hooks/ # settings.json compat (2110 lines) - see AGENTS.md +├── claude-code-hooks/ # settings.json compat (2110 lines) — see AGENTS.md ├── comment-checker/ # Prevents AI slop comments (710 lines) ├── compaction-context-injector/ # Injects context on compaction (128 lines) ├── compaction-todo-preserver/ # Preserves todos during compaction (203 lines) -├── context-window-monitor.ts # Reminds of headroom at 70% (99 lines) +├── context-window-monitor.ts # Reminds of headroom at 70% (100 lines) ├── delegate-task-retry/ # Retries failed delegations (266 lines) ├── directory-agents-injector/ # Auto-injects AGENTS.md (195 lines) ├── directory-readme-injector/ # Auto-injects README.md (190 lines) @@ -34,7 +34,7 @@ hooks/ ├── ralph-loop/ # Self-referential dev loop (1687 lines) ├── rules-injector/ # Conditional .sisyphus/rules injection (1604 lines) ├── session-notification.ts # OS idle notifications (108 lines) -├── session-recovery/ # Auto-recovers from crashes (1279 lines) +├── session-recovery/ # Auto-recovers from crashes (1279 lines, 14 files) ├── sisyphus-junior-notepad/ # Junior notepad directive (76 lines) ├── start-work/ # Sisyphus work session starter (648 lines) ├── stop-continuation-guard/ # Guards stop continuation (214 lines) @@ -57,10 +57,10 @@ hooks/ | UserPromptSubmit | `chat.message` | Yes | 4 | | ChatParams | `chat.params` | No | 2 | | PreToolUse | `tool.execute.before` | Yes | 13 | -| PostToolUse | `tool.execute.after` | No | 18 | +| PostToolUse | `tool.execute.after` | No | 15 | | SessionEvent | `event` | No | 17 | | MessagesTransform | `experimental.chat.messages.transform` | No | 1 | -| Compaction | `onSummarize` | No | 1 | +| Compaction | `onSummarize` | No | 2 | ## BLOCKING HOOKS (8) @@ -78,7 +78,7 @@ hooks/ ## EXECUTION ORDER **UserPromptSubmit**: keywordDetector → claudeCodeHooks → autoSlashCommand → startWork -**PreToolUse**: subagentQuestionBlocker → questionLabelTruncator → claudeCodeHooks → nonInteractiveEnv → commentChecker → directoryAgentsInjector → directoryReadmeInjector → rulesInjector → prometheusMdOnly → sisyphusJuniorNotepad → writeExistingFileGuard → atlasHook +**PreToolUse**: subagentQuestionBlocker → questionLabelTruncator → claudeCodeHooks → nonInteractiveEnv → commentChecker → directoryAgentsInjector → directoryReadmeInjector → rulesInjector → prometheusMdOnly → sisyphusJuniorNotepad → writeExistingFileGuard → tasksToDoWriteDisabler → atlasHook **PostToolUse**: claudeCodeHooks → toolOutputTruncator → contextWindowMonitor → commentChecker → directoryAgentsInjector → directoryReadmeInjector → rulesInjector → emptyTaskResponseDetector → agentUsageReminder → interactiveBashSession → editErrorRecovery → delegateTaskRetry → atlasHook → taskResumeInfo → taskReminder ## HOW TO ADD diff --git a/src/hooks/agent-usage-reminder/constants.ts b/src/hooks/agent-usage-reminder/constants.ts index 17be086d..d49b92b5 100644 --- a/src/hooks/agent-usage-reminder/constants.ts +++ b/src/hooks/agent-usage-reminder/constants.ts @@ -1,7 +1,5 @@ import { join } from "node:path"; -import { getOpenCodeStorageDir } from "../../shared/data-path"; - -export const OPENCODE_STORAGE = getOpenCodeStorageDir(); +import { OPENCODE_STORAGE } from "../../shared"; export const AGENT_USAGE_REMINDER_STORAGE = join( OPENCODE_STORAGE, "agent-usage-reminder", diff --git a/src/hooks/anthropic-context-window-limit-recovery/aggressive-truncation-strategy.ts b/src/hooks/anthropic-context-window-limit-recovery/aggressive-truncation-strategy.ts index 709cb0db..29a8d394 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/aggressive-truncation-strategy.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/aggressive-truncation-strategy.ts @@ -25,12 +25,13 @@ export async function runAggressiveTruncationStrategy(params: { targetRatio: TRUNCATE_CONFIG.targetTokenRatio, }) - const aggressiveResult = truncateUntilTargetTokens( + const aggressiveResult = await truncateUntilTargetTokens( params.sessionID, params.currentTokens, params.maxTokens, TRUNCATE_CONFIG.targetTokenRatio, TRUNCATE_CONFIG.charsPerToken, + params.client, ) if (aggressiveResult.truncatedCount <= 0) { @@ -60,7 +61,7 @@ export async function runAggressiveTruncationStrategy(params: { clearSessionState(params.autoCompactState, params.sessionID) setTimeout(async () => { try { - await params.client.session.prompt_async({ + await params.client.session.promptAsync({ path: { id: params.sessionID }, body: { auto: true } as never, query: { directory: params.directory }, diff --git a/src/hooks/anthropic-context-window-limit-recovery/client.ts b/src/hooks/anthropic-context-window-limit-recovery/client.ts index 13bef9ae..0ecaa263 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/client.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/client.ts @@ -1,20 +1,8 @@ -export type Client = { +import type { PluginInput } from "@opencode-ai/plugin" + +export type Client = PluginInput["client"] & { session: { - messages: (opts: { - path: { id: string } - query?: { directory?: string } - }) => Promise - summarize: (opts: { - path: { id: string } - body: { providerID: string; modelID: string } - query: { directory: string } - }) => Promise - revert: (opts: { - path: { id: string } - body: { messageID: string; partID?: string } - query: { directory: string } - }) => Promise - prompt_async: (opts: { + promptAsync: (opts: { path: { id: string } body: { parts: Array<{ type: string; text: string }> } query: { directory: string } diff --git a/src/hooks/anthropic-context-window-limit-recovery/deduplication-recovery.ts b/src/hooks/anthropic-context-window-limit-recovery/deduplication-recovery.ts index d7cb0314..5a76be36 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/deduplication-recovery.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/deduplication-recovery.ts @@ -1,3 +1,4 @@ +import type { PluginInput } from "@opencode-ai/plugin" import type { ParsedTokenLimitError } from "./types" import type { ExperimentalConfig } from "../../config" import type { DeduplicationConfig } from "./pruning-deduplication" @@ -6,6 +7,8 @@ import { executeDeduplication } from "./pruning-deduplication" import { truncateToolOutputsByCallId } from "./pruning-tool-output-truncation" import { log } from "../../shared/logger" +type OpencodeClient = PluginInput["client"] + function createPruningState(): PruningState { return { toolIdsToPrune: new Set(), @@ -43,6 +46,7 @@ export async function attemptDeduplicationRecovery( sessionID: string, parsed: ParsedTokenLimitError, experimental: ExperimentalConfig | undefined, + client?: OpencodeClient, ): Promise { if (!isPromptTooLongError(parsed)) return @@ -50,15 +54,17 @@ export async function attemptDeduplicationRecovery( if (!plan) return const pruningState = createPruningState() - const prunedCount = executeDeduplication( + const prunedCount = await executeDeduplication( sessionID, pruningState, plan.config, plan.protectedTools, + client, ) - const { truncatedCount } = truncateToolOutputsByCallId( + const { truncatedCount } = await truncateToolOutputsByCallId( sessionID, pruningState.toolIdsToPrune, + client, ) if (prunedCount > 0 || truncatedCount > 0) { diff --git a/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.test.ts b/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.test.ts new file mode 100644 index 00000000..e7d0e8ee --- /dev/null +++ b/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, mock, beforeEach } from "bun:test" +import { fixEmptyMessagesWithSDK } from "./empty-content-recovery-sdk" + +const mockReplaceEmptyTextParts = mock(() => Promise.resolve(false)) +const mockInjectTextPart = mock(() => Promise.resolve(false)) + +mock.module("../session-recovery/storage/empty-text", () => ({ + replaceEmptyTextPartsAsync: mockReplaceEmptyTextParts, +})) +mock.module("../session-recovery/storage/text-part-injector", () => ({ + injectTextPartAsync: mockInjectTextPart, +})) + +function createMockClient(messages: Array<{ info?: { id?: string }; parts?: Array<{ type?: string; text?: string }> }>) { + return { + session: { + messages: mock(() => Promise.resolve({ data: messages })), + }, + } as never +} + +describe("fixEmptyMessagesWithSDK", () => { + beforeEach(() => { + mockReplaceEmptyTextParts.mockReset() + mockInjectTextPart.mockReset() + mockReplaceEmptyTextParts.mockReturnValue(Promise.resolve(false)) + mockInjectTextPart.mockReturnValue(Promise.resolve(false)) + }) + + it("returns fixed=false when no empty messages exist", async () => { + //#given + const client = createMockClient([ + { info: { id: "msg_1" }, parts: [{ type: "text", text: "Hello" }] }, + ]) + + //#when + const result = await fixEmptyMessagesWithSDK({ + sessionID: "ses_1", + client, + placeholderText: "[recovered]", + }) + + //#then + expect(result.fixed).toBe(false) + expect(result.fixedMessageIds).toEqual([]) + expect(result.scannedEmptyCount).toBe(0) + }) + + it("fixes empty message via replace when scanning all", async () => { + //#given + const client = createMockClient([ + { info: { id: "msg_1" }, parts: [{ type: "text", text: "" }] }, + ]) + mockReplaceEmptyTextParts.mockReturnValue(Promise.resolve(true)) + + //#when + const result = await fixEmptyMessagesWithSDK({ + sessionID: "ses_1", + client, + placeholderText: "[recovered]", + }) + + //#then + expect(result.fixed).toBe(true) + expect(result.fixedMessageIds).toContain("msg_1") + expect(result.scannedEmptyCount).toBe(1) + }) + + it("falls back to inject when replace fails", async () => { + //#given + const client = createMockClient([ + { info: { id: "msg_1" }, parts: [] }, + ]) + mockReplaceEmptyTextParts.mockReturnValue(Promise.resolve(false)) + mockInjectTextPart.mockReturnValue(Promise.resolve(true)) + + //#when + const result = await fixEmptyMessagesWithSDK({ + sessionID: "ses_1", + client, + placeholderText: "[recovered]", + }) + + //#then + expect(result.fixed).toBe(true) + expect(result.fixedMessageIds).toContain("msg_1") + }) + + it("fixes target message by index when provided", async () => { + //#given + const client = createMockClient([ + { info: { id: "msg_0" }, parts: [{ type: "text", text: "ok" }] }, + { info: { id: "msg_1" }, parts: [] }, + ]) + mockReplaceEmptyTextParts.mockReturnValue(Promise.resolve(true)) + + //#when + const result = await fixEmptyMessagesWithSDK({ + sessionID: "ses_1", + client, + placeholderText: "[recovered]", + messageIndex: 1, + }) + + //#then + expect(result.fixed).toBe(true) + expect(result.fixedMessageIds).toContain("msg_1") + expect(result.scannedEmptyCount).toBe(0) + }) + + it("skips messages without info.id", async () => { + //#given + const client = createMockClient([ + { parts: [] }, + { info: {}, parts: [] }, + ]) + + //#when + const result = await fixEmptyMessagesWithSDK({ + sessionID: "ses_1", + client, + placeholderText: "[recovered]", + }) + + //#then + expect(result.fixed).toBe(false) + expect(result.scannedEmptyCount).toBe(0) + }) + + it("treats thinking-only messages as empty", async () => { + //#given + const client = createMockClient([ + { info: { id: "msg_1" }, parts: [{ type: "thinking", text: "hmm" }] }, + ]) + mockReplaceEmptyTextParts.mockReturnValue(Promise.resolve(true)) + + //#when + const result = await fixEmptyMessagesWithSDK({ + sessionID: "ses_1", + client, + placeholderText: "[recovered]", + }) + + //#then + expect(result.fixed).toBe(true) + expect(result.fixedMessageIds).toContain("msg_1") + }) + + it("treats tool_use messages as non-empty", async () => { + //#given + const client = createMockClient([ + { info: { id: "msg_1" }, parts: [{ type: "tool_use" }] }, + ]) + + //#when + const result = await fixEmptyMessagesWithSDK({ + sessionID: "ses_1", + client, + placeholderText: "[recovered]", + }) + + //#then + expect(result.fixed).toBe(false) + expect(result.scannedEmptyCount).toBe(0) + }) +}) diff --git a/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.ts b/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.ts new file mode 100644 index 00000000..f95a0b51 --- /dev/null +++ b/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.ts @@ -0,0 +1,194 @@ +import { replaceEmptyTextPartsAsync } from "../session-recovery/storage/empty-text" +import { injectTextPartAsync } from "../session-recovery/storage/text-part-injector" +import type { Client } from "./client" + +interface SDKPart { + id?: string + type?: string + text?: string +} + +interface SDKMessage { + info?: { id?: string } + parts?: SDKPart[] +} + +const IGNORE_TYPES = new Set(["thinking", "redacted_thinking", "meta"]) +const TOOL_TYPES = new Set(["tool", "tool_use", "tool_result"]) + +function messageHasContentFromSDK(message: SDKMessage): boolean { + const parts = message.parts + if (!parts || parts.length === 0) return false + + let hasIgnoredParts = false + + for (const part of parts) { + const type = part.type + if (!type) continue + if (IGNORE_TYPES.has(type)) { + hasIgnoredParts = true + continue + } + + if (type === "text") { + if (part.text?.trim()) return true + continue + } + + if (TOOL_TYPES.has(type)) return true + + return true + } + + // Messages with only thinking/meta parts are treated as empty + // to align with file-based logic (messageHasContent) + return false +} + +function getSdkMessages(response: unknown): SDKMessage[] { + if (typeof response !== "object" || response === null) return [] + if (Array.isArray(response)) return response as SDKMessage[] + const record = response as Record + const data = record["data"] + if (Array.isArray(data)) return data as SDKMessage[] + return Array.isArray(record) ? (record as SDKMessage[]) : [] +} + +async function findEmptyMessagesFromSDK(client: Client, sessionID: string): Promise { + try { + const response = await client.session.messages({ path: { id: sessionID } }) + const messages = getSdkMessages(response) + + const emptyIds: string[] = [] + for (const message of messages) { + const messageID = message.info?.id + if (!messageID) continue + if (!messageHasContentFromSDK(message)) { + emptyIds.push(messageID) + } + } + + return emptyIds + } catch { + return [] + } +} + +async function findEmptyMessageByIndexFromSDK( + client: Client, + sessionID: string, + targetIndex: number, +): Promise { + try { + const response = await client.session.messages({ path: { id: sessionID } }) + const messages = getSdkMessages(response) + + const indicesToTry = [ + targetIndex, + targetIndex - 1, + targetIndex + 1, + targetIndex - 2, + targetIndex + 2, + targetIndex - 3, + targetIndex - 4, + targetIndex - 5, + ] + + for (const index of indicesToTry) { + if (index < 0 || index >= messages.length) continue + + const targetMessage = messages[index] + const targetMessageId = targetMessage?.info?.id + if (!targetMessageId) continue + + if (!messageHasContentFromSDK(targetMessage)) { + return targetMessageId + } + } + + return null + } catch { + return null + } +} + +export async function fixEmptyMessagesWithSDK(params: { + sessionID: string + client: Client + placeholderText: string + messageIndex?: number +}): Promise<{ fixed: boolean; fixedMessageIds: string[]; scannedEmptyCount: number }> { + let fixed = false + const fixedMessageIds: string[] = [] + + if (params.messageIndex !== undefined) { + const targetMessageId = await findEmptyMessageByIndexFromSDK( + params.client, + params.sessionID, + params.messageIndex, + ) + + if (targetMessageId) { + const replaced = await replaceEmptyTextPartsAsync( + params.client, + params.sessionID, + targetMessageId, + params.placeholderText, + ) + + if (replaced) { + fixed = true + fixedMessageIds.push(targetMessageId) + } else { + const injected = await injectTextPartAsync( + params.client, + params.sessionID, + targetMessageId, + params.placeholderText, + ) + + if (injected) { + fixed = true + fixedMessageIds.push(targetMessageId) + } + } + } + } + + if (fixed) { + return { fixed, fixedMessageIds, scannedEmptyCount: 0 } + } + + const emptyMessageIds = await findEmptyMessagesFromSDK(params.client, params.sessionID) + if (emptyMessageIds.length === 0) { + return { fixed: false, fixedMessageIds: [], scannedEmptyCount: 0 } + } + + for (const messageID of emptyMessageIds) { + const replaced = await replaceEmptyTextPartsAsync( + params.client, + params.sessionID, + messageID, + params.placeholderText, + ) + + if (replaced) { + fixed = true + fixedMessageIds.push(messageID) + } else { + const injected = await injectTextPartAsync( + params.client, + params.sessionID, + messageID, + params.placeholderText, + ) + + if (injected) { + fixed = true + fixedMessageIds.push(messageID) + } + } + } + + return { fixed, fixedMessageIds, scannedEmptyCount: emptyMessageIds.length } +} diff --git a/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery.ts b/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery.ts index 140d98aa..f6f407e8 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery.ts @@ -4,10 +4,12 @@ import { injectTextPart, replaceEmptyTextParts, } from "../session-recovery/storage" +import { isSqliteBackend } from "../../shared/opencode-storage-detection" import type { AutoCompactState } from "./types" import type { Client } from "./client" import { PLACEHOLDER_TEXT } from "./message-builder" import { incrementEmptyContentAttempt } from "./state" +import { fixEmptyMessagesWithSDK } from "./empty-content-recovery-sdk" export async function fixEmptyMessages(params: { sessionID: string @@ -20,6 +22,44 @@ export async function fixEmptyMessages(params: { let fixed = false const fixedMessageIds: string[] = [] + if (isSqliteBackend()) { + const result = await fixEmptyMessagesWithSDK({ + sessionID: params.sessionID, + client: params.client, + placeholderText: PLACEHOLDER_TEXT, + messageIndex: params.messageIndex, + }) + + if (!result.fixed && result.scannedEmptyCount === 0) { + await params.client.tui + .showToast({ + body: { + title: "Empty Content Error", + message: "No empty messages found in storage. Cannot auto-recover.", + variant: "error", + duration: 5000, + }, + }) + .catch(() => {}) + return false + } + + if (result.fixed) { + await params.client.tui + .showToast({ + body: { + title: "Session Recovery", + message: `Fixed ${result.fixedMessageIds.length} empty message(s). Retrying...`, + variant: "warning", + duration: 3000, + }, + }) + .catch(() => {}) + } + + return result.fixed + } + if (params.messageIndex !== undefined) { const targetMessageId = findEmptyMessageByIndex(params.sessionID, params.messageIndex) if (targetMessageId) { diff --git a/src/hooks/anthropic-context-window-limit-recovery/executor.test.ts b/src/hooks/anthropic-context-window-limit-recovery/executor.test.ts index aa1fea43..8efb76de 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/executor.test.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/executor.test.ts @@ -99,7 +99,7 @@ describe("executeCompact lock management", () => { messages: mock(() => Promise.resolve({ data: [] })), summarize: mock(() => Promise.resolve()), revert: mock(() => Promise.resolve()), - prompt_async: mock(() => Promise.resolve()), + promptAsync: mock(() => Promise.resolve()), }, tui: { showToast: mock(() => Promise.resolve()), @@ -283,9 +283,9 @@ describe("executeCompact lock management", () => { expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false) }) - test("clears lock when prompt_async in continuation throws", async () => { - // given: prompt_async will fail during continuation - mockClient.session.prompt_async = mock(() => + test("clears lock when promptAsync in continuation throws", async () => { + // given: promptAsync will fail during continuation + mockClient.session.promptAsync = mock(() => Promise.reject(new Error("Prompt failed")), ) autoCompactState.errorDataBySession.set(sessionID, { @@ -313,7 +313,7 @@ describe("executeCompact lock management", () => { maxTokens: 200000, }) - const truncateSpy = spyOn(storage, "truncateUntilTargetTokens").mockReturnValue({ + const truncateSpy = spyOn(storage, "truncateUntilTargetTokens").mockResolvedValue({ success: true, sufficient: false, truncatedCount: 3, @@ -354,7 +354,7 @@ describe("executeCompact lock management", () => { maxTokens: 200000, }) - const truncateSpy = spyOn(storage, "truncateUntilTargetTokens").mockReturnValue({ + const truncateSpy = spyOn(storage, "truncateUntilTargetTokens").mockResolvedValue({ success: true, sufficient: true, truncatedCount: 5, @@ -378,8 +378,8 @@ describe("executeCompact lock management", () => { // then: Summarize should NOT be called (early return from sufficient truncation) expect(mockClient.session.summarize).not.toHaveBeenCalled() - // then: prompt_async should be called (Continue after successful truncation) - expect(mockClient.session.prompt_async).toHaveBeenCalled() + // then: promptAsync should be called (Continue after successful truncation) + expect(mockClient.session.promptAsync).toHaveBeenCalled() // then: Lock should be cleared expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false) diff --git a/src/hooks/anthropic-context-window-limit-recovery/message-builder.ts b/src/hooks/anthropic-context-window-limit-recovery/message-builder.ts index cb600ca2..17f24220 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/message-builder.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/message-builder.ts @@ -1,14 +1,121 @@ import { log } from "../../shared/logger" +import type { PluginInput } from "@opencode-ai/plugin" +import { normalizeSDKResponse } from "../../shared" +import { isSqliteBackend } from "../../shared/opencode-storage-detection" import { findEmptyMessages, injectTextPart, replaceEmptyTextParts, } from "../session-recovery/storage" +import { replaceEmptyTextPartsAsync } from "../session-recovery/storage/empty-text" +import { injectTextPartAsync } from "../session-recovery/storage/text-part-injector" import type { Client } from "./client" export const PLACEHOLDER_TEXT = "[user interrupted]" -export function sanitizeEmptyMessagesBeforeSummarize(sessionID: string): number { +type OpencodeClient = PluginInput["client"] + +interface SDKPart { + type?: string + text?: string +} + +interface SDKMessage { + info?: { id?: string } + parts?: SDKPart[] +} + +const IGNORE_TYPES = new Set(["thinking", "redacted_thinking", "meta"]) +const TOOL_TYPES = new Set(["tool", "tool_use", "tool_result"]) + +function messageHasContentFromSDK(message: SDKMessage): boolean { + const parts = message.parts + if (!parts || parts.length === 0) return false + + let hasIgnoredParts = false + + for (const part of parts) { + const type = part.type + if (!type) continue + if (IGNORE_TYPES.has(type)) { + hasIgnoredParts = true + continue + } + + if (type === "text") { + if (part.text?.trim()) return true + continue + } + + if (TOOL_TYPES.has(type)) return true + + return true + } + + // Messages with only thinking/meta parts are treated as empty + // to align with file-based logic (messageHasContent) + return false +} + +async function findEmptyMessageIdsFromSDK( + client: OpencodeClient, + sessionID: string, +): Promise { + try { + const response = (await client.session.messages({ + path: { id: sessionID }, + })) as { data?: SDKMessage[] } + const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true }) + + const emptyIds: string[] = [] + for (const message of messages) { + const messageID = message.info?.id + if (!messageID) continue + if (!messageHasContentFromSDK(message)) { + emptyIds.push(messageID) + } + } + + return emptyIds + } catch { + return [] + } +} + +export async function sanitizeEmptyMessagesBeforeSummarize( + sessionID: string, + client?: OpencodeClient, +): Promise { + if (client && isSqliteBackend()) { + const emptyMessageIds = await findEmptyMessageIdsFromSDK(client, sessionID) + if (emptyMessageIds.length === 0) { + return 0 + } + + let fixedCount = 0 + for (const messageID of emptyMessageIds) { + const replaced = await replaceEmptyTextPartsAsync(client, sessionID, messageID, PLACEHOLDER_TEXT) + if (replaced) { + fixedCount++ + } else { + const injected = await injectTextPartAsync(client, sessionID, messageID, PLACEHOLDER_TEXT) + if (injected) { + fixedCount++ + } + } + } + + if (fixedCount > 0) { + log("[auto-compact] pre-summarize sanitization fixed empty messages", { + sessionID, + fixedCount, + totalEmpty: emptyMessageIds.length, + }) + } + + return fixedCount + } + const emptyMessageIds = findEmptyMessages(sessionID) if (emptyMessageIds.length === 0) { return 0 diff --git a/src/hooks/anthropic-context-window-limit-recovery/message-storage-directory.ts b/src/hooks/anthropic-context-window-limit-recovery/message-storage-directory.ts index 249e4644..f4a7e576 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/message-storage-directory.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/message-storage-directory.ts @@ -1,36 +1,40 @@ import { existsSync, readdirSync } from "node:fs" -import { join } from "node:path" +import type { PluginInput } from "@opencode-ai/plugin" +import { getMessageDir } from "../../shared/opencode-message-dir" +import { normalizeSDKResponse } from "../../shared" -import { MESSAGE_STORAGE_DIR } from "./storage-paths" +export { getMessageDir } -export function getMessageDir(sessionID: string): string { - if (!existsSync(MESSAGE_STORAGE_DIR)) return "" +type OpencodeClient = PluginInput["client"] - const directPath = join(MESSAGE_STORAGE_DIR, sessionID) - if (existsSync(directPath)) { - return directPath - } +interface SDKMessage { + info: { id: string } + parts: unknown[] +} - for (const directory of readdirSync(MESSAGE_STORAGE_DIR)) { - const sessionPath = join(MESSAGE_STORAGE_DIR, directory, sessionID) - if (existsSync(sessionPath)) { - return sessionPath - } - } - - return "" +export async function getMessageIdsFromSDK( + client: OpencodeClient, + sessionID: string +): Promise { + try { + const response = await client.session.messages({ path: { id: sessionID } }) + const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true }) + return messages.map(msg => msg.info.id) + } catch { + return [] + } } export function getMessageIds(sessionID: string): string[] { - const messageDir = getMessageDir(sessionID) - if (!messageDir || !existsSync(messageDir)) return [] + const messageDir = getMessageDir(sessionID) + if (!messageDir || !existsSync(messageDir)) return [] - const messageIds: string[] = [] - for (const file of readdirSync(messageDir)) { - if (!file.endsWith(".json")) continue - const messageId = file.replace(".json", "") - messageIds.push(messageId) - } + const messageIds: string[] = [] + for (const file of readdirSync(messageDir)) { + if (!file.endsWith(".json")) continue + const messageId = file.replace(".json", "") + messageIds.push(messageId) + } - return messageIds + return messageIds } diff --git a/src/hooks/anthropic-context-window-limit-recovery/pruning-deduplication.ts b/src/hooks/anthropic-context-window-limit-recovery/pruning-deduplication.ts index b3e8b520..ef1a761c 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/pruning-deduplication.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/pruning-deduplication.ts @@ -1,9 +1,14 @@ -import { existsSync, readdirSync, readFileSync } from "node:fs" +import { readdirSync, readFileSync } from "node:fs" import { join } from "node:path" +import type { PluginInput } from "@opencode-ai/plugin" import type { PruningState, ToolCallSignature } from "./pruning-types" import { estimateTokens } from "./pruning-types" import { log } from "../../shared/logger" -import { MESSAGE_STORAGE } from "../../features/hook-message-injector" +import { getMessageDir } from "../../shared/opencode-message-dir" +import { isSqliteBackend } from "../../shared/opencode-storage-detection" +import { normalizeSDKResponse } from "../../shared" + +type OpencodeClient = PluginInput["client"] export interface DeduplicationConfig { enabled: boolean @@ -43,20 +48,6 @@ function sortObject(obj: unknown): unknown { return sorted } -function getMessageDir(sessionID: string): string | null { - if (!existsSync(MESSAGE_STORAGE)) return null - - const directPath = join(MESSAGE_STORAGE, sessionID) - if (existsSync(directPath)) return directPath - - for (const dir of readdirSync(MESSAGE_STORAGE)) { - const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) - if (existsSync(sessionPath)) return sessionPath - } - - return null -} - function readMessages(sessionID: string): MessagePart[] { const messageDir = getMessageDir(sessionID) if (!messageDir) return [] @@ -64,7 +55,7 @@ function readMessages(sessionID: string): MessagePart[] { const messages: MessagePart[] = [] try { - const files = readdirSync(messageDir).filter(f => f.endsWith(".json")) + const files = readdirSync(messageDir).filter((f: string) => f.endsWith(".json")) for (const file of files) { const content = readFileSync(join(messageDir, file), "utf-8") const data = JSON.parse(content) @@ -79,15 +70,29 @@ function readMessages(sessionID: string): MessagePart[] { return messages } -export function executeDeduplication( +async function readMessagesFromSDK(client: OpencodeClient, sessionID: string): Promise { + try { + const response = await client.session.messages({ path: { id: sessionID } }) + const rawMessages = normalizeSDKResponse(response, [] as Array<{ parts?: ToolPart[] }>, { preferResponseOnMissingData: true }) + return rawMessages.filter((m) => m.parts) as MessagePart[] + } catch { + return [] + } +} + +export async function executeDeduplication( sessionID: string, state: PruningState, config: DeduplicationConfig, - protectedTools: Set -): number { + protectedTools: Set, + client?: OpencodeClient, +): Promise { if (!config.enabled) return 0 - const messages = readMessages(sessionID) + const messages = (client && isSqliteBackend()) + ? await readMessagesFromSDK(client, sessionID) + : readMessages(sessionID) + const signatures = new Map() let currentTurn = 0 diff --git a/src/hooks/anthropic-context-window-limit-recovery/pruning-tool-output-truncation.ts b/src/hooks/anthropic-context-window-limit-recovery/pruning-tool-output-truncation.ts index 0481e94c..4c3741aa 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/pruning-tool-output-truncation.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/pruning-tool-output-truncation.ts @@ -1,8 +1,15 @@ import { existsSync, readdirSync, readFileSync } from "node:fs" import { join } from "node:path" +import type { PluginInput } from "@opencode-ai/plugin" import { getOpenCodeStorageDir } from "../../shared/data-path" import { truncateToolResult } from "./storage" +import { truncateToolResultAsync } from "./tool-result-storage-sdk" import { log } from "../../shared/logger" +import { getMessageDir } from "../../shared/opencode-message-dir" +import { isSqliteBackend } from "../../shared/opencode-storage-detection" +import { normalizeSDKResponse } from "../../shared" + +type OpencodeClient = PluginInput["client"] interface StoredToolPart { type?: string @@ -13,29 +20,23 @@ interface StoredToolPart { } } -function getMessageStorage(): string { - return join(getOpenCodeStorageDir(), "message") +interface SDKToolPart { + id: string + type: string + callID?: string + tool?: string + state?: { output?: string; time?: { compacted?: number } } +} + +interface SDKMessage { + info?: { id?: string } + parts?: SDKToolPart[] } function getPartStorage(): string { return join(getOpenCodeStorageDir(), "part") } -function getMessageDir(sessionID: string): string | null { - const messageStorage = getMessageStorage() - if (!existsSync(messageStorage)) return null - - const directPath = join(messageStorage, sessionID) - if (existsSync(directPath)) return directPath - - for (const dir of readdirSync(messageStorage)) { - const sessionPath = join(messageStorage, dir, sessionID) - if (existsSync(sessionPath)) return sessionPath - } - - return null -} - function getMessageIds(sessionID: string): string[] { const messageDir = getMessageDir(sessionID) if (!messageDir) return [] @@ -49,12 +50,17 @@ function getMessageIds(sessionID: string): string[] { return messageIds } -export function truncateToolOutputsByCallId( +export async function truncateToolOutputsByCallId( sessionID: string, callIds: Set, -): { truncatedCount: number } { + client?: OpencodeClient, +): Promise<{ truncatedCount: number }> { if (callIds.size === 0) return { truncatedCount: 0 } + if (client && isSqliteBackend()) { + return truncateToolOutputsByCallIdFromSDK(client, sessionID, callIds) + } + const messageIds = getMessageIds(sessionID) if (messageIds.length === 0) return { truncatedCount: 0 } @@ -95,3 +101,42 @@ export function truncateToolOutputsByCallId( return { truncatedCount } } + +async function truncateToolOutputsByCallIdFromSDK( + client: OpencodeClient, + sessionID: string, + callIds: Set, +): Promise<{ truncatedCount: number }> { + try { + const response = await client.session.messages({ path: { id: sessionID } }) + const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true }) + let truncatedCount = 0 + + for (const msg of messages) { + const messageID = msg.info?.id + if (!messageID || !msg.parts) continue + + for (const part of msg.parts) { + if (part.type !== "tool" || !part.callID) continue + if (!callIds.has(part.callID)) continue + if (!part.state?.output || part.state?.time?.compacted) continue + + const result = await truncateToolResultAsync(client, sessionID, messageID, part.id, part) + if (result.success) { + truncatedCount++ + } + } + } + + if (truncatedCount > 0) { + log("[auto-compact] pruned duplicate tool outputs (SDK)", { + sessionID, + truncatedCount, + }) + } + + return { truncatedCount } + } catch { + return { truncatedCount: 0 } + } +} diff --git a/src/hooks/anthropic-context-window-limit-recovery/recovery-deduplication.test.ts b/src/hooks/anthropic-context-window-limit-recovery/recovery-deduplication.test.ts index 2e877277..65db7298 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/recovery-deduplication.test.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/recovery-deduplication.test.ts @@ -53,7 +53,7 @@ describe("createAnthropicContextWindowLimitRecoveryHook", () => { messages: mock(() => Promise.resolve({ data: [] })), summarize: mock(() => summarizePromise), revert: mock(() => Promise.resolve()), - prompt_async: mock(() => Promise.resolve()), + promptAsync: mock(() => Promise.resolve()), }, tui: { showToast: mock(() => Promise.resolve()), @@ -97,7 +97,7 @@ describe("createAnthropicContextWindowLimitRecoveryHook", () => { messages: mock(() => Promise.resolve({ data: [] })), summarize: mock(() => Promise.resolve()), revert: mock(() => Promise.resolve()), - prompt_async: mock(() => Promise.resolve()), + promptAsync: mock(() => Promise.resolve()), }, tui: { showToast: mock(() => Promise.resolve()), diff --git a/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.test.ts b/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.test.ts new file mode 100644 index 00000000..610c21a4 --- /dev/null +++ b/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test" +import type { PluginInput } from "@opencode-ai/plugin" + +const executeCompactMock = mock(async () => {}) +const getLastAssistantMock = mock(async () => ({ + providerID: "anthropic", + modelID: "claude-sonnet-4-5", +})) +const parseAnthropicTokenLimitErrorMock = mock(() => ({ + providerID: "anthropic", + modelID: "claude-sonnet-4-5", +})) + +mock.module("./executor", () => ({ + executeCompact: executeCompactMock, + getLastAssistant: getLastAssistantMock, +})) + +mock.module("./parser", () => ({ + parseAnthropicTokenLimitError: parseAnthropicTokenLimitErrorMock, +})) + +mock.module("../../shared/logger", () => ({ + log: () => {}, +})) + +function createMockContext(): PluginInput { + return { + client: { + session: { + messages: mock(() => Promise.resolve({ data: [] })), + }, + tui: { + showToast: mock(() => Promise.resolve()), + }, + }, + directory: "/tmp", + } as PluginInput +} + +function setupDelayedTimeoutMocks(): { + restore: () => void + getClearTimeoutCalls: () => Array> +} { + const originalSetTimeout = globalThis.setTimeout + const originalClearTimeout = globalThis.clearTimeout + const clearTimeoutCalls: Array> = [] + let timeoutCounter = 0 + + globalThis.setTimeout = ((_: () => void, _delay?: number) => { + timeoutCounter += 1 + return timeoutCounter as ReturnType + }) as typeof setTimeout + + globalThis.clearTimeout = ((timeoutID: ReturnType) => { + clearTimeoutCalls.push(timeoutID) + }) as typeof clearTimeout + + return { + restore: () => { + globalThis.setTimeout = originalSetTimeout + globalThis.clearTimeout = originalClearTimeout + }, + getClearTimeoutCalls: () => clearTimeoutCalls, + } +} + +describe("createAnthropicContextWindowLimitRecoveryHook", () => { + beforeEach(() => { + executeCompactMock.mockClear() + getLastAssistantMock.mockClear() + parseAnthropicTokenLimitErrorMock.mockClear() + }) + + test("cancels pending timer when session.idle handles compaction first", async () => { + //#given + const { restore, getClearTimeoutCalls } = setupDelayedTimeoutMocks() + const { createAnthropicContextWindowLimitRecoveryHook } = await import("./recovery-hook") + const hook = createAnthropicContextWindowLimitRecoveryHook(createMockContext()) + + try { + //#when + await hook.event({ + event: { + type: "session.error", + properties: { sessionID: "session-race", error: "prompt is too long" }, + }, + }) + + await hook.event({ + event: { + type: "session.idle", + properties: { sessionID: "session-race" }, + }, + }) + + //#then + expect(getClearTimeoutCalls()).toEqual([1 as ReturnType]) + expect(executeCompactMock).toHaveBeenCalledTimes(1) + expect(executeCompactMock.mock.calls[0]?.[0]).toBe("session-race") + } finally { + restore() + } + }) +}) diff --git a/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.ts b/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.ts index f4bcb0f2..e7064b4f 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.ts @@ -28,6 +28,7 @@ export function createAnthropicContextWindowLimitRecoveryHook( ) { const autoCompactState = createRecoveryState() const experimental = options?.experimental + const pendingCompactionTimeoutBySession = new Map>() const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => { const props = event.properties as Record | undefined @@ -35,6 +36,12 @@ export function createAnthropicContextWindowLimitRecoveryHook( if (event.type === "session.deleted") { const sessionInfo = props?.info as { id?: string } | undefined if (sessionInfo?.id) { + const timeoutID = pendingCompactionTimeoutBySession.get(sessionInfo.id) + if (timeoutID !== undefined) { + clearTimeout(timeoutID) + pendingCompactionTimeoutBySession.delete(sessionInfo.id) + } + autoCompactState.pendingCompact.delete(sessionInfo.id) autoCompactState.errorDataBySession.delete(sessionInfo.id) autoCompactState.retryStateBySession.delete(sessionInfo.id) @@ -57,7 +64,7 @@ export function createAnthropicContextWindowLimitRecoveryHook( autoCompactState.errorDataBySession.set(sessionID, parsed) if (autoCompactState.compactionInProgress.has(sessionID)) { - await attemptDeduplicationRecovery(sessionID, parsed, experimental) + await attemptDeduplicationRecovery(sessionID, parsed, experimental, ctx.client) return } @@ -76,7 +83,8 @@ export function createAnthropicContextWindowLimitRecoveryHook( }) .catch(() => {}) - setTimeout(() => { + const timeoutID = setTimeout(() => { + pendingCompactionTimeoutBySession.delete(sessionID) executeCompact( sessionID, { providerID, modelID }, @@ -86,6 +94,8 @@ export function createAnthropicContextWindowLimitRecoveryHook( experimental, ) }, 300) + + pendingCompactionTimeoutBySession.set(sessionID, timeoutID) } return } @@ -114,6 +124,12 @@ export function createAnthropicContextWindowLimitRecoveryHook( if (!autoCompactState.pendingCompact.has(sessionID)) return + const timeoutID = pendingCompactionTimeoutBySession.get(sessionID) + if (timeoutID !== undefined) { + clearTimeout(timeoutID) + pendingCompactionTimeoutBySession.delete(sessionID) + } + const errorData = autoCompactState.errorDataBySession.get(sessionID) const lastAssistant = await getLastAssistant(sessionID, ctx.client, ctx.directory) diff --git a/src/hooks/anthropic-context-window-limit-recovery/storage-paths.ts b/src/hooks/anthropic-context-window-limit-recovery/storage-paths.ts index 95825a0a..249603fa 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/storage-paths.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/storage-paths.ts @@ -1,10 +1,6 @@ -import { join } from "node:path" -import { getOpenCodeStorageDir } from "../../shared/data-path" +import { MESSAGE_STORAGE, PART_STORAGE } from "../../shared" -const OPENCODE_STORAGE_DIR = getOpenCodeStorageDir() - -export const MESSAGE_STORAGE_DIR = join(OPENCODE_STORAGE_DIR, "message") -export const PART_STORAGE_DIR = join(OPENCODE_STORAGE_DIR, "part") +export { MESSAGE_STORAGE as MESSAGE_STORAGE_DIR, PART_STORAGE as PART_STORAGE_DIR } export const TRUNCATION_MESSAGE = "[TOOL RESULT TRUNCATED - Context limit exceeded. Original output was too large and has been truncated to recover the session. Please re-run this tool if you need the full output.]" diff --git a/src/hooks/anthropic-context-window-limit-recovery/storage.test.ts b/src/hooks/anthropic-context-window-limit-recovery/storage.test.ts index d5797590..ffe1fabc 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/storage.test.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/storage.test.ts @@ -21,7 +21,7 @@ describe("truncateUntilTargetTokens", () => { truncateToolResult.mockReset() }) - test("truncates only until target is reached", () => { + test("truncates only until target is reached", async () => { const { findToolResultsBySize, truncateToolResult } = require("./storage") // given: Two tool results, each 1000 chars. Target reduction is 500 chars. @@ -39,7 +39,7 @@ describe("truncateUntilTargetTokens", () => { // when: currentTokens=1000, maxTokens=1000, targetRatio=0.5 (target=500, reduce=500) // charsPerToken=1 for simplicity in test - const result = truncateUntilTargetTokens(sessionID, 1000, 1000, 0.5, 1) + const result = await truncateUntilTargetTokens(sessionID, 1000, 1000, 0.5, 1) // then: Should only truncate the first tool expect(result.truncatedCount).toBe(1) @@ -49,7 +49,7 @@ describe("truncateUntilTargetTokens", () => { expect(result.sufficient).toBe(true) }) - test("truncates all if target not reached", () => { + test("truncates all if target not reached", async () => { const { findToolResultsBySize, truncateToolResult } = require("./storage") // given: Two tool results, each 100 chars. Target reduction is 500 chars. @@ -66,7 +66,7 @@ describe("truncateUntilTargetTokens", () => { })) // when: reduce 500 chars - const result = truncateUntilTargetTokens(sessionID, 1000, 1000, 0.5, 1) + const result = await truncateUntilTargetTokens(sessionID, 1000, 1000, 0.5, 1) // then: Should truncate both expect(result.truncatedCount).toBe(2) diff --git a/src/hooks/anthropic-context-window-limit-recovery/storage.ts b/src/hooks/anthropic-context-window-limit-recovery/storage.ts index 3cd302c8..2f2136fd 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/storage.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/storage.ts @@ -8,4 +8,11 @@ export { truncateToolResult, } from "./tool-result-storage" +export { + countTruncatedResultsFromSDK, + findToolResultsBySizeFromSDK, + getTotalToolOutputSizeFromSDK, + truncateToolResultAsync, +} from "./tool-result-storage-sdk" + export { truncateUntilTargetTokens } from "./target-token-truncation" diff --git a/src/hooks/anthropic-context-window-limit-recovery/summarize-retry-strategy.ts b/src/hooks/anthropic-context-window-limit-recovery/summarize-retry-strategy.ts index 41db33d0..7c57c841 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/summarize-retry-strategy.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/summarize-retry-strategy.ts @@ -61,7 +61,7 @@ export async function runSummarizeRetryStrategy(params: { if (providerID && modelID) { try { - sanitizeEmptyMessagesBeforeSummarize(params.sessionID) + await sanitizeEmptyMessagesBeforeSummarize(params.sessionID, params.client) await params.client.tui .showToast({ diff --git a/src/hooks/anthropic-context-window-limit-recovery/target-token-truncation.ts b/src/hooks/anthropic-context-window-limit-recovery/target-token-truncation.ts index 6e5ea6c2..f7d8dff9 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/target-token-truncation.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/target-token-truncation.ts @@ -1,5 +1,27 @@ +import type { PluginInput } from "@opencode-ai/plugin" import type { AggressiveTruncateResult } from "./tool-part-types" import { findToolResultsBySize, truncateToolResult } from "./tool-result-storage" +import { truncateToolResultAsync } from "./tool-result-storage-sdk" +import { isSqliteBackend } from "../../shared/opencode-storage-detection" +import { normalizeSDKResponse } from "../../shared" + +type OpencodeClient = PluginInput["client"] + +interface SDKToolPart { + id: string + type: string + tool?: string + state?: { + output?: string + time?: { start?: number; end?: number; compacted?: number } + } + originalSize?: number +} + +interface SDKMessage { + info?: { id?: string } + parts?: SDKToolPart[] +} function calculateTargetBytesToRemove( currentTokens: number, @@ -13,13 +35,14 @@ function calculateTargetBytesToRemove( return { tokensToReduce, targetBytesToRemove } } -export function truncateUntilTargetTokens( +export async function truncateUntilTargetTokens( sessionID: string, currentTokens: number, maxTokens: number, targetRatio: number = 0.8, - charsPerToken: number = 4 -): AggressiveTruncateResult { + charsPerToken: number = 4, + client?: OpencodeClient +): Promise { const { tokensToReduce, targetBytesToRemove } = calculateTargetBytesToRemove( currentTokens, maxTokens, @@ -38,6 +61,94 @@ export function truncateUntilTargetTokens( } } + if (client && isSqliteBackend()) { + let toolPartsByKey = new Map() + try { + const response = (await client.session.messages({ + path: { id: sessionID }, + })) as { data?: SDKMessage[] } + const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true }) + toolPartsByKey = new Map() + + for (const message of messages) { + const messageID = message.info?.id + if (!messageID || !message.parts) continue + for (const part of message.parts) { + if (part.type !== "tool") continue + toolPartsByKey.set(`${messageID}:${part.id}`, part) + } + } + } catch { + toolPartsByKey = new Map() + } + + const results: import("./tool-part-types").ToolResultInfo[] = [] + for (const [key, part] of toolPartsByKey) { + if (part.type === "tool" && part.state?.output && !part.state?.time?.compacted && part.tool) { + results.push({ + partPath: "", + partId: part.id, + messageID: key.split(":")[0], + toolName: part.tool, + outputSize: part.state.output.length, + }) + } + } + results.sort((a, b) => b.outputSize - a.outputSize) + + if (results.length === 0) { + return { + success: false, + sufficient: false, + truncatedCount: 0, + totalBytesRemoved: 0, + targetBytesToRemove, + truncatedTools: [], + } + } + + let totalRemoved = 0 + let truncatedCount = 0 + const truncatedTools: Array<{ toolName: string; originalSize: number }> = [] + + for (const result of results) { + const part = toolPartsByKey.get(`${result.messageID}:${result.partId}`) + if (!part) continue + + const truncateResult = await truncateToolResultAsync( + client, + sessionID, + result.messageID, + result.partId, + part + ) + if (truncateResult.success) { + truncatedCount++ + const removedSize = truncateResult.originalSize ?? result.outputSize + totalRemoved += removedSize + truncatedTools.push({ + toolName: truncateResult.toolName ?? result.toolName, + originalSize: removedSize, + }) + + if (totalRemoved >= targetBytesToRemove) { + break + } + } + } + + const sufficient = totalRemoved >= targetBytesToRemove + + return { + success: truncatedCount > 0, + sufficient, + truncatedCount, + totalBytesRemoved: totalRemoved, + targetBytesToRemove, + truncatedTools, + } + } + const results = findToolResultsBySize(sessionID) if (results.length === 0) { diff --git a/src/hooks/anthropic-context-window-limit-recovery/tool-result-storage-sdk.ts b/src/hooks/anthropic-context-window-limit-recovery/tool-result-storage-sdk.ts new file mode 100644 index 00000000..c163a636 --- /dev/null +++ b/src/hooks/anthropic-context-window-limit-recovery/tool-result-storage-sdk.ts @@ -0,0 +1,124 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { getMessageIdsFromSDK } from "./message-storage-directory" +import { TRUNCATION_MESSAGE } from "./storage-paths" +import type { ToolResultInfo } from "./tool-part-types" +import { patchPart } from "../../shared/opencode-http-api" +import { log } from "../../shared/logger" +import { normalizeSDKResponse } from "../../shared" + +type OpencodeClient = PluginInput["client"] + +interface SDKToolPart { + id: string + type: string + callID?: string + tool?: string + state?: { + status?: string + input?: Record + output?: string + error?: string + time?: { start?: number; end?: number; compacted?: number } + } +} + +interface SDKMessage { + info?: { id?: string } + parts?: SDKToolPart[] +} + +export async function findToolResultsBySizeFromSDK( + client: OpencodeClient, + sessionID: string +): Promise { + try { + const response = await client.session.messages({ path: { id: sessionID } }) + const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true }) + const results: ToolResultInfo[] = [] + + for (const msg of messages) { + const messageID = msg.info?.id + if (!messageID || !msg.parts) continue + + for (const part of msg.parts) { + if (part.type === "tool" && part.state?.output && !part.state?.time?.compacted && part.tool) { + results.push({ + partPath: "", + partId: part.id, + messageID, + toolName: part.tool, + outputSize: part.state.output.length, + }) + } + } + } + + return results.sort((a, b) => b.outputSize - a.outputSize) + } catch { + return [] + } +} + +export async function truncateToolResultAsync( + client: OpencodeClient, + sessionID: string, + messageID: string, + partId: string, + part: SDKToolPart +): Promise<{ success: boolean; toolName?: string; originalSize?: number }> { + if (!part.state?.output) return { success: false } + + const originalSize = part.state.output.length + const toolName = part.tool + + const updatedPart: Record = { + ...part, + state: { + ...part.state, + output: TRUNCATION_MESSAGE, + time: { + ...(part.state.time ?? { start: Date.now() }), + compacted: Date.now(), + }, + }, + } + + try { + const patched = await patchPart(client, sessionID, messageID, partId, updatedPart) + if (!patched) return { success: false } + return { success: true, toolName, originalSize } + } catch (error) { + log("[context-window-recovery] truncateToolResultAsync failed", { error: String(error) }) + return { success: false } + } +} + +export async function countTruncatedResultsFromSDK( + client: OpencodeClient, + sessionID: string +): Promise { + try { + const response = await client.session.messages({ path: { id: sessionID } }) + const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true }) + let count = 0 + + for (const msg of messages) { + if (!msg.parts) continue + for (const part of msg.parts) { + if (part.type === "tool" && part.state?.time?.compacted) count++ + } + } + + return count + } catch { + return 0 + } +} + +export async function getTotalToolOutputSizeFromSDK( + client: OpencodeClient, + sessionID: string +): Promise { + const results = await findToolResultsBySizeFromSDK(client, sessionID) + return results.reduce((sum, result) => sum + result.outputSize, 0) +} diff --git a/src/hooks/anthropic-context-window-limit-recovery/tool-result-storage.ts b/src/hooks/anthropic-context-window-limit-recovery/tool-result-storage.ts index 70d9ffa5..c1af7df6 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/tool-result-storage.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/tool-result-storage.ts @@ -4,6 +4,10 @@ import { join } from "node:path" import { getMessageIds } from "./message-storage-directory" import { PART_STORAGE_DIR, TRUNCATION_MESSAGE } from "./storage-paths" import type { StoredToolPart, ToolResultInfo } from "./tool-part-types" +import { isSqliteBackend } from "../../shared/opencode-storage-detection" +import { log } from "../../shared/logger" + +let hasLoggedTruncateWarning = false export function findToolResultsBySize(sessionID: string): ToolResultInfo[] { const messageIds = getMessageIds(sessionID) @@ -48,6 +52,14 @@ export function truncateToolResult(partPath: string): { toolName?: string originalSize?: number } { + if (isSqliteBackend()) { + if (!hasLoggedTruncateWarning) { + log("[context-window-recovery] Disabled on SQLite backend: truncateToolResult") + hasLoggedTruncateWarning = true + } + return { success: false } + } + try { const content = readFileSync(partPath, "utf-8") const part = JSON.parse(content) as StoredToolPart diff --git a/src/hooks/atlas/atlas-hook.ts b/src/hooks/atlas/atlas-hook.ts index 5d8c47f4..94a6470e 100644 --- a/src/hooks/atlas/atlas-hook.ts +++ b/src/hooks/atlas/atlas-hook.ts @@ -19,7 +19,7 @@ export function createAtlasHook(ctx: PluginInput, options?: AtlasHookOptions) { return { handler: createAtlasEventHandler({ ctx, options, sessions, getState }), - "tool.execute.before": createToolExecuteBeforeHandler({ pendingFilePaths }), + "tool.execute.before": createToolExecuteBeforeHandler({ ctx, pendingFilePaths }), "tool.execute.after": createToolExecuteAfterHandler({ ctx, pendingFilePaths }), } } diff --git a/src/hooks/atlas/event-handler.ts b/src/hooks/atlas/event-handler.ts index 0857c3db..76a3a500 100644 --- a/src/hooks/atlas/event-handler.ts +++ b/src/hooks/atlas/event-handler.ts @@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import { getPlanProgress, readBoulderState } from "../../features/boulder-state" import { subagentSessions } from "../../features/claude-code-session-state" import { log } from "../../shared/logger" +import { getAgentConfigKey } from "../../shared/agent-display-names" import { HOOK_NAME } from "./hook-name" import { isAbortError } from "./is-abort-error" import { injectBoulderContinuation } from "./boulder-continuation-injector" @@ -87,12 +88,13 @@ export function createAtlasEventHandler(input: { return } - const lastAgent = getLastAgentFromSession(sessionID) - const requiredAgent = (boulderState.agent ?? "atlas").toLowerCase() - const lastAgentMatchesRequired = lastAgent === requiredAgent + const lastAgent = await getLastAgentFromSession(sessionID, ctx.client) + const lastAgentKey = getAgentConfigKey(lastAgent ?? "") + const requiredAgent = getAgentConfigKey(boulderState.agent ?? "atlas") + const lastAgentMatchesRequired = lastAgentKey === requiredAgent const boulderAgentWasNotExplicitlySet = boulderState.agent === undefined const boulderAgentDefaultsToAtlas = requiredAgent === "atlas" - const lastAgentIsSisyphus = lastAgent === "sisyphus" + const lastAgentIsSisyphus = lastAgentKey === "sisyphus" const allowSisyphusWhenDefaultAtlas = boulderAgentWasNotExplicitlySet && boulderAgentDefaultsToAtlas && lastAgentIsSisyphus const agentMatches = lastAgentMatchesRequired || allowSisyphusWhenDefaultAtlas if (!agentMatches) { diff --git a/src/hooks/atlas/index.test.ts b/src/hooks/atlas/index.test.ts index bf46e538..52025857 100644 --- a/src/hooks/atlas/index.test.ts +++ b/src/hooks/atlas/index.test.ts @@ -9,10 +9,31 @@ import { readBoulderState, } from "../../features/boulder-state" import type { BoulderState } from "../../features/boulder-state" - -import { MESSAGE_STORAGE } from "../../features/hook-message-injector" import { _resetForTesting, subagentSessions } from "../../features/claude-code-session-state" -import { createAtlasHook } from "./index" + +const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-message-storage-${randomUUID()}`) +const TEST_MESSAGE_STORAGE = join(TEST_STORAGE_ROOT, "message") +const TEST_PART_STORAGE = join(TEST_STORAGE_ROOT, "part") + +mock.module("../../features/hook-message-injector/constants", () => ({ + OPENCODE_STORAGE: TEST_STORAGE_ROOT, + MESSAGE_STORAGE: TEST_MESSAGE_STORAGE, + PART_STORAGE: TEST_PART_STORAGE, +})) + +mock.module("../../shared/opencode-message-dir", () => ({ + getMessageDir: (sessionID: string) => { + const dir = join(TEST_MESSAGE_STORAGE, sessionID) + return existsSync(dir) ? dir : null + }, +})) + +mock.module("../../shared/opencode-storage-detection", () => ({ + isSqliteBackend: () => false, +})) + +const { createAtlasHook } = await import("./index") +const { MESSAGE_STORAGE } = await import("../../features/hook-message-injector") describe("atlas hook", () => { let TEST_DIR: string diff --git a/src/hooks/atlas/recent-model-resolver.ts b/src/hooks/atlas/recent-model-resolver.ts index 814e6af8..ba6018b2 100644 --- a/src/hooks/atlas/recent-model-resolver.ts +++ b/src/hooks/atlas/recent-model-resolver.ts @@ -1,6 +1,9 @@ import type { PluginInput } from "@opencode-ai/plugin" -import { findNearestMessageWithFields } from "../../features/hook-message-injector" -import { getMessageDir } from "../../shared/session-utils" +import { + findNearestMessageWithFields, + findNearestMessageWithFieldsFromSDK, +} from "../../features/hook-message-injector" +import { getMessageDir, isSqliteBackend, normalizeSDKResponse } from "../../shared" import type { ModelInfo } from "./types" export async function resolveRecentModelForSession( @@ -9,9 +12,9 @@ export async function resolveRecentModelForSession( ): Promise { try { const messagesResp = await ctx.client.session.messages({ path: { id: sessionID } }) - const messages = (messagesResp.data ?? []) as Array<{ + const messages = normalizeSDKResponse(messagesResp, [] as Array<{ info?: { model?: ModelInfo; modelID?: string; providerID?: string } - }> + }>) for (let i = messages.length - 1; i >= 0; i--) { const info = messages[i].info @@ -28,8 +31,13 @@ export async function resolveRecentModelForSession( // ignore - fallback to message storage } - const messageDir = getMessageDir(sessionID) - const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null + let currentMessage = null + if (isSqliteBackend()) { + currentMessage = await findNearestMessageWithFieldsFromSDK(ctx.client, sessionID) + } else { + const messageDir = getMessageDir(sessionID) + currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null + } const model = currentMessage?.model if (!model?.providerID || !model?.modelID) { return undefined diff --git a/src/hooks/atlas/session-last-agent.ts b/src/hooks/atlas/session-last-agent.ts index 341eda6f..6ddbbacb 100644 --- a/src/hooks/atlas/session-last-agent.ts +++ b/src/hooks/atlas/session-last-agent.ts @@ -1,9 +1,24 @@ -import { findNearestMessageWithFields } from "../../features/hook-message-injector" -import { getMessageDir } from "../../shared/session-utils" +import type { PluginInput } from "@opencode-ai/plugin" + +import { findNearestMessageWithFields } from "../../features/hook-message-injector" +import { findNearestMessageWithFieldsFromSDK } from "../../features/hook-message-injector" +import { getMessageDir, isSqliteBackend } from "../../shared" + +type OpencodeClient = PluginInput["client"] + +export async function getLastAgentFromSession( + sessionID: string, + client?: OpencodeClient +): Promise { + let nearest = null + + if (isSqliteBackend() && client) { + nearest = await findNearestMessageWithFieldsFromSDK(client, sessionID) + } else { + const messageDir = getMessageDir(sessionID) + if (!messageDir) return null + nearest = findNearestMessageWithFields(messageDir) + } -export function getLastAgentFromSession(sessionID: string): string | null { - const messageDir = getMessageDir(sessionID) - if (!messageDir) return null - const nearest = findNearestMessageWithFields(messageDir) return nearest?.agent?.toLowerCase() ?? null } diff --git a/src/hooks/atlas/tool-execute-after.ts b/src/hooks/atlas/tool-execute-after.ts index f82f3e49..8a7240c4 100644 --- a/src/hooks/atlas/tool-execute-after.ts +++ b/src/hooks/atlas/tool-execute-after.ts @@ -23,7 +23,7 @@ export function createToolExecuteAfterHandler(input: { return } - if (!isCallerOrchestrator(toolInput.sessionID)) { + if (!(await isCallerOrchestrator(toolInput.sessionID, ctx.client))) { return } diff --git a/src/hooks/atlas/tool-execute-before.ts b/src/hooks/atlas/tool-execute-before.ts index 6fb6ba9d..51f67000 100644 --- a/src/hooks/atlas/tool-execute-before.ts +++ b/src/hooks/atlas/tool-execute-before.ts @@ -1,21 +1,23 @@ import { log } from "../../shared/logger" import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive" import { isCallerOrchestrator } from "../../shared/session-utils" +import type { PluginInput } from "@opencode-ai/plugin" import { HOOK_NAME } from "./hook-name" import { ORCHESTRATOR_DELEGATION_REQUIRED, SINGLE_TASK_DIRECTIVE } from "./system-reminder-templates" import { isSisyphusPath } from "./sisyphus-path" import { isWriteOrEditToolName } from "./write-edit-tool-policy" export function createToolExecuteBeforeHandler(input: { + ctx: PluginInput pendingFilePaths: Map }): ( toolInput: { tool: string; sessionID?: string; callID?: string }, toolOutput: { args: Record; message?: string } ) => Promise { - const { pendingFilePaths } = input + const { ctx, pendingFilePaths } = input return async (toolInput, toolOutput): Promise => { - if (!isCallerOrchestrator(toolInput.sessionID)) { + if (!(await isCallerOrchestrator(toolInput.sessionID, ctx.client))) { return } diff --git a/src/hooks/category-skill-reminder/hook.ts b/src/hooks/category-skill-reminder/hook.ts index b15715cd..a89d182b 100644 --- a/src/hooks/category-skill-reminder/hook.ts +++ b/src/hooks/category-skill-reminder/hook.ts @@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder" import { getSessionAgent } from "../../features/claude-code-session-state" import { log } from "../../shared" +import { getAgentConfigKey } from "../../shared/agent-display-names" import { buildReminderMessage } from "./formatter" /** @@ -75,11 +76,11 @@ export function createCategorySkillReminderHook( function isTargetAgent(sessionID: string, inputAgent?: string): boolean { const agent = getSessionAgent(sessionID) ?? inputAgent if (!agent) return false - const agentLower = agent.toLowerCase() + const agentKey = getAgentConfigKey(agent) return ( - TARGET_AGENTS.has(agentLower) || - agentLower.includes("sisyphus") || - agentLower.includes("atlas") + TARGET_AGENTS.has(agentKey) || + agentKey.includes("sisyphus") || + agentKey.includes("atlas") ) } diff --git a/src/hooks/claude-code-hooks/AGENTS.md b/src/hooks/claude-code-hooks/AGENTS.md index e9204a18..46d0d01a 100644 --- a/src/hooks/claude-code-hooks/AGENTS.md +++ b/src/hooks/claude-code-hooks/AGENTS.md @@ -2,7 +2,7 @@ ## OVERVIEW -Full Claude Code `settings.json` hook compatibility layer. Intercepts OpenCode events to execute external scripts/commands. +Full Claude Code `settings.json` hook compatibility layer. Intercepts OpenCode events to execute external scripts/commands defined in settings.json. **Config Sources** (priority): `.claude/settings.local.json` > `.claude/settings.json` (project) > `~/.claude/settings.json` (global) @@ -10,21 +10,26 @@ Full Claude Code `settings.json` hook compatibility layer. Intercepts OpenCode e ``` claude-code-hooks/ ├── index.ts # Barrel export -├── claude-code-hooks-hook.ts # Main factory -├── config.ts # Claude settings.json loader -├── config-loader.ts # Extended plugin config -├── pre-tool-use.ts # PreToolUse hook executor -├── post-tool-use.ts # PostToolUse hook executor -├── user-prompt-submit.ts # UserPromptSubmit executor -├── stop.ts # Stop hook executor -├── pre-compact.ts # PreCompact executor -├── transcript.ts # Tool use recording -├── tool-input-cache.ts # Pre→post input caching +├── claude-code-hooks-hook.ts # Main factory (22 lines) +├── config.ts # Claude settings.json loader (105 lines) +├── config-loader.ts # Extended plugin config (107 lines) +├── pre-tool-use.ts # PreToolUse hook executor (173 lines) +├── post-tool-use.ts # PostToolUse hook executor (200 lines) +├── user-prompt-submit.ts # UserPromptSubmit executor (125 lines) +├── stop.ts # Stop hook executor (122 lines) +├── pre-compact.ts # PreCompact executor (110 lines) +├── transcript.ts # Tool use recording (235 lines) +├── tool-input-cache.ts # Pre→post input caching (51 lines) ├── todo.ts # Todo integration -├── session-hook-state.ts # Active state tracking -├── types.ts # Hook & IO type definitions -├── plugin-config.ts # Default config constants +├── session-hook-state.ts # Active state tracking (11 lines) +├── types.ts # Hook & IO type definitions (204 lines) +├── plugin-config.ts # Default config constants (12 lines) └── handlers/ # Event handlers (5 files) + ├── pre-compact-handler.ts + ├── tool-execute-before-handler.ts + ├── tool-execute-after-handler.ts + ├── chat-message-handler.ts + └── session-event-handler.ts ``` ## HOOK LIFECYCLE diff --git a/src/hooks/directory-agents-injector/constants.ts b/src/hooks/directory-agents-injector/constants.ts index 3dc2e19f..4adda871 100644 --- a/src/hooks/directory-agents-injector/constants.ts +++ b/src/hooks/directory-agents-injector/constants.ts @@ -1,7 +1,5 @@ import { join } from "node:path"; -import { getOpenCodeStorageDir } from "../../shared/data-path"; - -export const OPENCODE_STORAGE = getOpenCodeStorageDir(); +import { OPENCODE_STORAGE } from "../../shared"; export const AGENTS_INJECTOR_STORAGE = join( OPENCODE_STORAGE, "directory-agents", diff --git a/src/hooks/directory-readme-injector/constants.ts b/src/hooks/directory-readme-injector/constants.ts index f5d9f494..69e1fc5f 100644 --- a/src/hooks/directory-readme-injector/constants.ts +++ b/src/hooks/directory-readme-injector/constants.ts @@ -1,7 +1,5 @@ import { join } from "node:path"; -import { getOpenCodeStorageDir } from "../../shared/data-path"; - -export const OPENCODE_STORAGE = getOpenCodeStorageDir(); +import { OPENCODE_STORAGE } from "../../shared"; export const README_INJECTOR_STORAGE = join( OPENCODE_STORAGE, "directory-readme", diff --git a/src/hooks/interactive-bash-session/constants.ts b/src/hooks/interactive-bash-session/constants.ts index 9b2ce382..2c820591 100644 --- a/src/hooks/interactive-bash-session/constants.ts +++ b/src/hooks/interactive-bash-session/constants.ts @@ -1,7 +1,5 @@ import { join } from "node:path"; -import { getOpenCodeStorageDir } from "../../shared/data-path"; - -export const OPENCODE_STORAGE = getOpenCodeStorageDir(); +import { OPENCODE_STORAGE } from "../../shared"; export const INTERACTIVE_BASH_SESSION_STORAGE = join( OPENCODE_STORAGE, "interactive-bash-session", diff --git a/src/hooks/preemptive-compaction.test.ts b/src/hooks/preemptive-compaction.test.ts index 81bb6bc7..4ef001e3 100644 --- a/src/hooks/preemptive-compaction.test.ts +++ b/src/hooks/preemptive-compaction.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect, mock, beforeEach } from "bun:test" -import { createPreemptiveCompactionHook } from "./preemptive-compaction" + +const logMock = mock(() => {}) + +mock.module("../shared/logger", () => ({ + log: logMock, +})) + +const { createPreemptiveCompactionHook } = await import("./preemptive-compaction") function createMockCtx() { return { @@ -21,6 +28,7 @@ describe("preemptive-compaction", () => { beforeEach(() => { ctx = createMockCtx() + logMock.mockClear() }) // #given event caches token info from message.updated @@ -152,4 +160,45 @@ describe("preemptive-compaction", () => { expect(ctx.client.session.summarize).not.toHaveBeenCalled() }) + + it("should log summarize errors instead of swallowing them", async () => { + //#given + const hook = createPreemptiveCompactionHook(ctx as never) + const sessionID = "ses_log_error" + const summarizeError = new Error("summarize failed") + ctx.client.session.summarize.mockRejectedValueOnce(summarizeError) + + await hook.event({ + event: { + type: "message.updated", + properties: { + info: { + role: "assistant", + sessionID, + providerID: "anthropic", + modelID: "claude-sonnet-4-5", + finish: true, + tokens: { + input: 170000, + output: 0, + reasoning: 0, + cache: { read: 10000, write: 0 }, + }, + }, + }, + }, + }) + + //#when + await hook["tool.execute.after"]( + { tool: "bash", sessionID, callID: "call_log" }, + { title: "", output: "test", metadata: null } + ) + + //#then + expect(logMock).toHaveBeenCalledWith("[preemptive-compaction] Compaction failed", { + sessionID, + error: String(summarizeError), + }) + }) }) diff --git a/src/hooks/preemptive-compaction.ts b/src/hooks/preemptive-compaction.ts index 87190415..fd617ccf 100644 --- a/src/hooks/preemptive-compaction.ts +++ b/src/hooks/preemptive-compaction.ts @@ -1,3 +1,5 @@ +import { log } from "../shared/logger" + const DEFAULT_ACTUAL_LIMIT = 200_000 const ANTHROPIC_ACTUAL_LIMIT = @@ -76,8 +78,8 @@ export function createPreemptiveCompactionHook(ctx: PluginInput) { }) compactedSessions.add(sessionID) - } catch { - // best-effort; do not disrupt tool execution + } catch (error) { + log("[preemptive-compaction] Compaction failed", { sessionID, error: String(error) }) } finally { compactionInProgress.delete(sessionID) } diff --git a/src/hooks/prometheus-md-only/agent-resolution.ts b/src/hooks/prometheus-md-only/agent-resolution.ts index b59c5a3a..22dc9cae 100644 --- a/src/hooks/prometheus-md-only/agent-resolution.ts +++ b/src/hooks/prometheus-md-only/agent-resolution.ts @@ -1,24 +1,29 @@ -import { existsSync, readdirSync } from "node:fs" -import { join } from "node:path" -import { findNearestMessageWithFields, findFirstMessageWithAgent, MESSAGE_STORAGE } from "../../features/hook-message-injector" +import type { PluginInput } from "@opencode-ai/plugin" + +import { findNearestMessageWithFields, findFirstMessageWithAgent } from "../../features/hook-message-injector" +import { + findFirstMessageWithAgentFromSDK, + findNearestMessageWithFieldsFromSDK, +} from "../../features/hook-message-injector" import { getSessionAgent } from "../../features/claude-code-session-state" import { readBoulderState } from "../../features/boulder-state" +import { getMessageDir } from "../../shared/opencode-message-dir" +import { isSqliteBackend } from "../../shared/opencode-storage-detection" -function getMessageDir(sessionID: string): string | null { - if (!existsSync(MESSAGE_STORAGE)) return null +type OpencodeClient = PluginInput["client"] - const directPath = join(MESSAGE_STORAGE, sessionID) - if (existsSync(directPath)) return directPath +async function getAgentFromMessageFiles( + sessionID: string, + client?: OpencodeClient +): Promise { + if (isSqliteBackend() && client) { + const firstAgent = await findFirstMessageWithAgentFromSDK(client, sessionID) + if (firstAgent) return firstAgent - for (const dir of readdirSync(MESSAGE_STORAGE)) { - const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) - if (existsSync(sessionPath)) return sessionPath + const nearest = await findNearestMessageWithFieldsFromSDK(client, sessionID) + return nearest?.agent } - return null -} - -function getAgentFromMessageFiles(sessionID: string): string | undefined { const messageDir = getMessageDir(sessionID) if (!messageDir) return undefined return findFirstMessageWithAgent(messageDir) ?? findNearestMessageWithFields(messageDir)?.agent @@ -36,7 +41,11 @@ function getAgentFromMessageFiles(sessionID: string): string | undefined { * - Message files return "prometheus" (oldest message from /plan) * - But boulder.json has agent: "atlas" (set by /start-work) */ -export function getAgentFromSession(sessionID: string, directory: string): string | undefined { +export async function getAgentFromSession( + sessionID: string, + directory: string, + client?: OpencodeClient +): Promise { // Check in-memory first (current session) const memoryAgent = getSessionAgent(sessionID) if (memoryAgent) return memoryAgent @@ -48,5 +57,5 @@ export function getAgentFromSession(sessionID: string, directory: string): strin } // Fallback to message files - return getAgentFromMessageFiles(sessionID) + return await getAgentFromMessageFiles(sessionID, client) } diff --git a/src/hooks/prometheus-md-only/hook.ts b/src/hooks/prometheus-md-only/hook.ts index b0b5a01a..846238ba 100644 --- a/src/hooks/prometheus-md-only/hook.ts +++ b/src/hooks/prometheus-md-only/hook.ts @@ -15,7 +15,7 @@ export function createPrometheusMdOnlyHook(ctx: PluginInput) { input: { tool: string; sessionID: string; callID: string }, output: { args: Record; message?: string } ): Promise => { - const agentName = getAgentFromSession(input.sessionID, ctx.directory) + const agentName = await getAgentFromSession(input.sessionID, ctx.directory, ctx.client) if (!isPrometheusAgent(agentName)) { return diff --git a/src/hooks/prometheus-md-only/index.test.ts b/src/hooks/prometheus-md-only/index.test.ts index 54a839f9..cbb12208 100644 --- a/src/hooks/prometheus-md-only/index.test.ts +++ b/src/hooks/prometheus-md-only/index.test.ts @@ -1,16 +1,21 @@ -import { describe, expect, test, beforeEach, afterEach } from "bun:test" +import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test" import { mkdirSync, rmSync, writeFileSync } from "node:fs" import { join } from "node:path" import { tmpdir } from "node:os" import { randomUUID } from "node:crypto" import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive" import { clearSessionAgent } from "../../features/claude-code-session-state" +// Force stable (JSON) mode for tests that rely on message file storage +mock.module("../../shared/opencode-storage-detection", () => ({ + isSqliteBackend: () => false, + resetSqliteBackendCache: () => {}, +})) -import { createPrometheusMdOnlyHook } from "./index" -import { MESSAGE_STORAGE } from "../../features/hook-message-injector" +const { createPrometheusMdOnlyHook } = await import("./index") +const { MESSAGE_STORAGE } = await import("../../features/hook-message-injector") describe("prometheus-md-only", () => { - const TEST_SESSION_ID = "test-session-prometheus" + const TEST_SESSION_ID = "ses_test_prometheus" let testMessageDir: string function createMockPluginInput() { @@ -546,7 +551,7 @@ describe("prometheus-md-only", () => { writeFileSync(BOULDER_FILE, JSON.stringify({ active_plan: "/test/plan.md", started_at: new Date().toISOString(), - session_ids: ["other-session-id"], + session_ids: ["ses_other_session_id"], plan_name: "test-plan", agent: "atlas" })) @@ -578,7 +583,7 @@ describe("prometheus-md-only", () => { const hook = createPrometheusMdOnlyHook(createMockPluginInput()) const input = { tool: "Write", - sessionID: "non-existent-session", + sessionID: "ses_non_existent_session", callID: "call-1", } const output = { diff --git a/src/hooks/ralph-loop/continuation-prompt-injector.ts b/src/hooks/ralph-loop/continuation-prompt-injector.ts index 84af442f..d476fb26 100644 --- a/src/hooks/ralph-loop/continuation-prompt-injector.ts +++ b/src/hooks/ralph-loop/continuation-prompt-injector.ts @@ -3,6 +3,7 @@ import { log } from "../../shared/logger" import { findNearestMessageWithFields } from "../../features/hook-message-injector" import { getMessageDir } from "./message-storage-directory" import { withTimeout } from "./with-timeout" +import { normalizeSDKResponse } from "../../shared" type MessageInfo = { agent?: string @@ -25,7 +26,7 @@ export async function injectContinuationPrompt( }), options.apiTimeoutMs, ) - const messages = (messagesResp.data ?? []) as Array<{ info?: MessageInfo }> + const messages = normalizeSDKResponse(messagesResp, [] as Array<{ info?: MessageInfo }>) for (let i = messages.length - 1; i >= 0; i--) { const info = messages[i]?.info if (info?.agent || info?.model || (info?.modelID && info?.providerID)) { diff --git a/src/hooks/ralph-loop/message-storage-directory.ts b/src/hooks/ralph-loop/message-storage-directory.ts index 7d4caca1..a9111f43 100644 --- a/src/hooks/ralph-loop/message-storage-directory.ts +++ b/src/hooks/ralph-loop/message-storage-directory.ts @@ -1,16 +1 @@ -import { existsSync, readdirSync } from "node:fs" -import { join } from "node:path" -import { MESSAGE_STORAGE } from "../../features/hook-message-injector" - -export function getMessageDir(sessionID: string): string | null { - if (!existsSync(MESSAGE_STORAGE)) return null - - const directPath = join(MESSAGE_STORAGE, sessionID) - if (existsSync(directPath)) return directPath - - for (const dir of readdirSync(MESSAGE_STORAGE)) { - const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) - if (existsSync(sessionPath)) return sessionPath - } - return null -} +export { getMessageDir } from "../../shared/opencode-message-dir" diff --git a/src/hooks/rules-injector/constants.ts b/src/hooks/rules-injector/constants.ts index 3f8b9f6f..6ac2cbbb 100644 --- a/src/hooks/rules-injector/constants.ts +++ b/src/hooks/rules-injector/constants.ts @@ -1,7 +1,5 @@ import { join } from "node:path"; -import { getOpenCodeStorageDir } from "../../shared/data-path"; - -export const OPENCODE_STORAGE = getOpenCodeStorageDir(); +import { OPENCODE_STORAGE } from "../../shared"; export const RULES_INJECTOR_STORAGE = join(OPENCODE_STORAGE, "rules-injector"); export const PROJECT_MARKERS = [ diff --git a/src/hooks/session-recovery/constants.ts b/src/hooks/session-recovery/constants.ts index a45b8026..8d5ea5e4 100644 --- a/src/hooks/session-recovery/constants.ts +++ b/src/hooks/session-recovery/constants.ts @@ -1,9 +1,4 @@ -import { join } from "node:path" -import { getOpenCodeStorageDir } from "../../shared/data-path" - -export const OPENCODE_STORAGE = getOpenCodeStorageDir() -export const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message") -export const PART_STORAGE = join(OPENCODE_STORAGE, "part") +export { OPENCODE_STORAGE, MESSAGE_STORAGE, PART_STORAGE } from "../../shared" export const THINKING_TYPES = new Set(["thinking", "redacted_thinking", "reasoning"]) export const META_TYPES = new Set(["step-start", "step-finish"]) diff --git a/src/hooks/session-recovery/recover-empty-content-message-sdk.test.ts b/src/hooks/session-recovery/recover-empty-content-message-sdk.test.ts new file mode 100644 index 00000000..acf178ec --- /dev/null +++ b/src/hooks/session-recovery/recover-empty-content-message-sdk.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, mock, beforeEach } from "bun:test" +import { recoverEmptyContentMessageFromSDK } from "./recover-empty-content-message-sdk" +import type { MessageData } from "./types" + +function createMockClient(messages: MessageData[]) { + return { + session: { + messages: mock(() => Promise.resolve({ data: messages })), + }, + } as never +} + +function createDeps(overrides?: Partial[4]>) { + return { + placeholderText: "[recovered]", + replaceEmptyTextPartsAsync: mock(() => Promise.resolve(false)), + injectTextPartAsync: mock(() => Promise.resolve(false)), + findMessagesWithEmptyTextPartsFromSDK: mock(() => Promise.resolve([] as string[])), + ...overrides, + } +} + +const emptyMsg: MessageData = { info: { id: "msg_1", role: "assistant" }, parts: [] } +const contentMsg: MessageData = { info: { id: "msg_2", role: "assistant" }, parts: [{ type: "text", text: "Hello" }] } +const thinkingOnlyMsg: MessageData = { info: { id: "msg_3", role: "assistant" }, parts: [{ type: "thinking", text: "hmm" }] } + +describe("recoverEmptyContentMessageFromSDK", () => { + it("returns false when no empty messages exist", async () => { + //#given + const client = createMockClient([contentMsg]) + const deps = createDeps() + + //#when + const result = await recoverEmptyContentMessageFromSDK( + client, "ses_1", contentMsg, new Error("test"), deps, + ) + + //#then + expect(result).toBe(false) + }) + + it("fixes messages with empty text parts via replace", async () => { + //#given + const client = createMockClient([emptyMsg]) + const deps = createDeps({ + findMessagesWithEmptyTextPartsFromSDK: mock(() => Promise.resolve(["msg_1"])), + replaceEmptyTextPartsAsync: mock(() => Promise.resolve(true)), + }) + + //#when + const result = await recoverEmptyContentMessageFromSDK( + client, "ses_1", emptyMsg, new Error("test"), deps, + ) + + //#then + expect(result).toBe(true) + }) + + it("injects text part into thinking-only messages", async () => { + //#given + const client = createMockClient([thinkingOnlyMsg]) + const deps = createDeps({ + injectTextPartAsync: mock(() => Promise.resolve(true)), + }) + + //#when + const result = await recoverEmptyContentMessageFromSDK( + client, "ses_1", thinkingOnlyMsg, new Error("test"), deps, + ) + + //#then + expect(result).toBe(true) + expect(deps.injectTextPartAsync).toHaveBeenCalledWith( + client, "ses_1", "msg_3", "[recovered]", + ) + }) + + it("targets message by index from error", async () => { + //#given + const client = createMockClient([contentMsg, emptyMsg]) + const error = new Error("messages: index 1 has empty content") + const deps = createDeps({ + replaceEmptyTextPartsAsync: mock(() => Promise.resolve(true)), + }) + + //#when + const result = await recoverEmptyContentMessageFromSDK( + client, "ses_1", emptyMsg, error, deps, + ) + + //#then + expect(result).toBe(true) + }) + + it("falls back to failedID when targetIndex fix fails", async () => { + //#given + const failedMsg: MessageData = { info: { id: "msg_fail" }, parts: [] } + const client = createMockClient([contentMsg]) + const deps = createDeps({ + replaceEmptyTextPartsAsync: mock(() => Promise.resolve(false)), + injectTextPartAsync: mock(() => Promise.resolve(true)), + }) + + //#when + const result = await recoverEmptyContentMessageFromSDK( + client, "ses_1", failedMsg, new Error("test"), deps, + ) + + //#then + expect(result).toBe(true) + expect(deps.injectTextPartAsync).toHaveBeenCalledWith( + client, "ses_1", "msg_fail", "[recovered]", + ) + }) + + it("returns false when SDK throws during message read", async () => { + //#given + const client = { session: { messages: mock(() => Promise.reject(new Error("SDK error"))) } } as never + const deps = createDeps() + + //#when + const result = await recoverEmptyContentMessageFromSDK( + client, "ses_1", emptyMsg, new Error("test"), deps, + ) + + //#then + expect(result).toBe(false) + }) + + it("scans all empty messages when no target index available", async () => { + //#given + const empty1: MessageData = { info: { id: "e1" }, parts: [] } + const empty2: MessageData = { info: { id: "e2" }, parts: [] } + const client = createMockClient([empty1, empty2]) + const replaceMock = mock(() => Promise.resolve(true)) + const deps = createDeps({ replaceEmptyTextPartsAsync: replaceMock }) + + //#when + const result = await recoverEmptyContentMessageFromSDK( + client, "ses_1", empty1, new Error("test"), deps, + ) + + //#then + expect(result).toBe(true) + }) +}) diff --git a/src/hooks/session-recovery/recover-empty-content-message-sdk.ts b/src/hooks/session-recovery/recover-empty-content-message-sdk.ts new file mode 100644 index 00000000..ee6ab54e --- /dev/null +++ b/src/hooks/session-recovery/recover-empty-content-message-sdk.ts @@ -0,0 +1,201 @@ +import type { createOpencodeClient } from "@opencode-ai/sdk" +import type { MessageData } from "./types" +import { extractMessageIndex } from "./detect-error-type" +import { META_TYPES, THINKING_TYPES } from "./constants" +import { normalizeSDKResponse } from "../../shared" + +type Client = ReturnType + +type ReplaceEmptyTextPartsAsync = ( + client: Client, + sessionID: string, + messageID: string, + replacementText: string +) => Promise + +type InjectTextPartAsync = ( + client: Client, + sessionID: string, + messageID: string, + text: string +) => Promise + +type FindMessagesWithEmptyTextPartsFromSDK = ( + client: Client, + sessionID: string +) => Promise + +export async function recoverEmptyContentMessageFromSDK( + client: Client, + sessionID: string, + failedAssistantMsg: MessageData, + error: unknown, + dependencies: { + placeholderText: string + replaceEmptyTextPartsAsync: ReplaceEmptyTextPartsAsync + injectTextPartAsync: InjectTextPartAsync + findMessagesWithEmptyTextPartsFromSDK: FindMessagesWithEmptyTextPartsFromSDK + } +): Promise { + const targetIndex = extractMessageIndex(error) + const failedID = failedAssistantMsg.info?.id + let anySuccess = false + + const messagesWithEmptyText = await dependencies.findMessagesWithEmptyTextPartsFromSDK(client, sessionID) + for (const messageID of messagesWithEmptyText) { + if ( + await dependencies.replaceEmptyTextPartsAsync( + client, + sessionID, + messageID, + dependencies.placeholderText + ) + ) { + anySuccess = true + } + } + + const messages = await readMessagesFromSDK(client, sessionID) + + const thinkingOnlyIDs = findMessagesWithThinkingOnlyFromSDK(messages) + for (const messageID of thinkingOnlyIDs) { + if (await dependencies.injectTextPartAsync(client, sessionID, messageID, dependencies.placeholderText)) { + anySuccess = true + } + } + + if (targetIndex !== null) { + const targetMessageID = findEmptyMessageByIndexFromSDK(messages, targetIndex) + if (targetMessageID) { + if ( + await dependencies.replaceEmptyTextPartsAsync( + client, + sessionID, + targetMessageID, + dependencies.placeholderText + ) + ) { + return true + } + if (await dependencies.injectTextPartAsync(client, sessionID, targetMessageID, dependencies.placeholderText)) { + return true + } + } + } + + if (failedID) { + if (await dependencies.replaceEmptyTextPartsAsync(client, sessionID, failedID, dependencies.placeholderText)) { + return true + } + if (await dependencies.injectTextPartAsync(client, sessionID, failedID, dependencies.placeholderText)) { + return true + } + } + + const freshMessages = await readMessagesFromSDK(client, sessionID) + const emptyMessageIDs = findEmptyMessagesFromSDK(freshMessages) + for (const messageID of emptyMessageIDs) { + if ( + await dependencies.replaceEmptyTextPartsAsync( + client, + sessionID, + messageID, + dependencies.placeholderText + ) + ) { + anySuccess = true + } + if (await dependencies.injectTextPartAsync(client, sessionID, messageID, dependencies.placeholderText)) { + anySuccess = true + } + } + + return anySuccess +} + +type SdkPart = NonNullable[number] + +function sdkPartHasContent(part: SdkPart): boolean { + if (THINKING_TYPES.has(part.type)) return false + if (META_TYPES.has(part.type)) return false + + if (part.type === "text") { + return !!part.text?.trim() + } + + if (part.type === "tool" || part.type === "tool_use" || part.type === "tool_result") { + return true + } + + return true +} + +function sdkMessageHasContent(message: MessageData): boolean { + return (message.parts ?? []).some(sdkPartHasContent) +} + +async function readMessagesFromSDK(client: Client, sessionID: string): Promise { + try { + const response = await client.session.messages({ path: { id: sessionID } }) + return normalizeSDKResponse(response, [] as MessageData[], { preferResponseOnMissingData: true }) + } catch { + return [] + } +} + +function findMessagesWithThinkingOnlyFromSDK(messages: MessageData[]): string[] { + const result: string[] = [] + + for (const msg of messages) { + if (msg.info?.role !== "assistant") continue + if (!msg.info?.id) continue + if (!msg.parts || msg.parts.length === 0) continue + + const hasThinking = msg.parts.some((part) => THINKING_TYPES.has(part.type)) + const hasContent = msg.parts.some(sdkPartHasContent) + + if (hasThinking && !hasContent) { + result.push(msg.info.id) + } + } + + return result +} + +function findEmptyMessagesFromSDK(messages: MessageData[]): string[] { + const emptyIds: string[] = [] + + for (const msg of messages) { + if (!msg.info?.id) continue + if (!sdkMessageHasContent(msg)) { + emptyIds.push(msg.info.id) + } + } + + return emptyIds +} + +function findEmptyMessageByIndexFromSDK(messages: MessageData[], targetIndex: number): string | null { + const indicesToTry = [ + targetIndex, + targetIndex - 1, + targetIndex + 1, + targetIndex - 2, + targetIndex + 2, + targetIndex - 3, + targetIndex - 4, + targetIndex - 5, + ] + + for (const index of indicesToTry) { + if (index < 0 || index >= messages.length) continue + const targetMessage = messages[index] + if (!targetMessage.info?.id) continue + + if (!sdkMessageHasContent(targetMessage)) { + return targetMessage.info.id + } + } + + return null +} diff --git a/src/hooks/session-recovery/recover-empty-content-message.ts b/src/hooks/session-recovery/recover-empty-content-message.ts index f095eb2e..7b73f34f 100644 --- a/src/hooks/session-recovery/recover-empty-content-message.ts +++ b/src/hooks/session-recovery/recover-empty-content-message.ts @@ -1,6 +1,7 @@ import type { createOpencodeClient } from "@opencode-ai/sdk" import type { MessageData } from "./types" import { extractMessageIndex } from "./detect-error-type" +import { recoverEmptyContentMessageFromSDK } from "./recover-empty-content-message-sdk" import { findEmptyMessageByIndex, findEmptyMessages, @@ -9,18 +10,30 @@ import { injectTextPart, replaceEmptyTextParts, } from "./storage" +import { isSqliteBackend } from "../../shared/opencode-storage-detection" +import { replaceEmptyTextPartsAsync, findMessagesWithEmptyTextPartsFromSDK } from "./storage/empty-text" +import { injectTextPartAsync } from "./storage/text-part-injector" type Client = ReturnType const PLACEHOLDER_TEXT = "[user interrupted]" export async function recoverEmptyContentMessage( - _client: Client, + client: Client, sessionID: string, failedAssistantMsg: MessageData, _directory: string, error: unknown ): Promise { + if (isSqliteBackend()) { + return recoverEmptyContentMessageFromSDK(client, sessionID, failedAssistantMsg, error, { + placeholderText: PLACEHOLDER_TEXT, + replaceEmptyTextPartsAsync, + injectTextPartAsync, + findMessagesWithEmptyTextPartsFromSDK, + }) + } + const targetIndex = extractMessageIndex(error) const failedID = failedAssistantMsg.info?.id let anySuccess = false diff --git a/src/hooks/session-recovery/recover-thinking-block-order.ts b/src/hooks/session-recovery/recover-thinking-block-order.ts index f26bf4f1..cd62b97c 100644 --- a/src/hooks/session-recovery/recover-thinking-block-order.ts +++ b/src/hooks/session-recovery/recover-thinking-block-order.ts @@ -2,16 +2,24 @@ import type { createOpencodeClient } from "@opencode-ai/sdk" import type { MessageData } from "./types" import { extractMessageIndex } from "./detect-error-type" import { findMessageByIndexNeedingThinking, findMessagesWithOrphanThinking, prependThinkingPart } from "./storage" +import { isSqliteBackend } from "../../shared/opencode-storage-detection" +import { prependThinkingPartAsync } from "./storage/thinking-prepend" +import { THINKING_TYPES } from "./constants" +import { normalizeSDKResponse } from "../../shared" type Client = ReturnType export async function recoverThinkingBlockOrder( - _client: Client, + client: Client, sessionID: string, _failedAssistantMsg: MessageData, _directory: string, error: unknown ): Promise { + if (isSqliteBackend()) { + return recoverThinkingBlockOrderFromSDK(client, sessionID, error) + } + const targetIndex = extractMessageIndex(error) if (targetIndex !== null) { const targetMessageID = findMessageByIndexNeedingThinking(sessionID, targetIndex) @@ -34,3 +42,96 @@ export async function recoverThinkingBlockOrder( return anySuccess } + +async function recoverThinkingBlockOrderFromSDK( + client: Client, + sessionID: string, + error: unknown +): Promise { + const targetIndex = extractMessageIndex(error) + if (targetIndex !== null) { + const targetMessageID = await findMessageByIndexNeedingThinkingFromSDK(client, sessionID, targetIndex) + if (targetMessageID) { + return prependThinkingPartAsync(client, sessionID, targetMessageID) + } + } + + const orphanMessages = await findMessagesWithOrphanThinkingFromSDK(client, sessionID) + if (orphanMessages.length === 0) { + return false + } + + let anySuccess = false + for (const messageID of orphanMessages) { + if (await prependThinkingPartAsync(client, sessionID, messageID)) { + anySuccess = true + } + } + + return anySuccess +} + +async function findMessagesWithOrphanThinkingFromSDK( + client: Client, + sessionID: string +): Promise { + let messages: MessageData[] + try { + const response = await client.session.messages({ path: { id: sessionID } }) + messages = normalizeSDKResponse(response, [] as MessageData[], { preferResponseOnMissingData: true }) + } catch { + return [] + } + + const result: string[] = [] + for (const msg of messages) { + if (msg.info?.role !== "assistant") continue + if (!msg.info?.id) continue + if (!msg.parts || msg.parts.length === 0) continue + + const partsWithIds = msg.parts.filter( + (part): part is { id: string; type: string } => typeof part.id === "string" + ) + if (partsWithIds.length === 0) continue + + const sortedParts = [...partsWithIds].sort((a, b) => a.id.localeCompare(b.id)) + const firstPart = sortedParts[0] + if (!THINKING_TYPES.has(firstPart.type)) { + result.push(msg.info.id) + } + } + + return result +} + +async function findMessageByIndexNeedingThinkingFromSDK( + client: Client, + sessionID: string, + targetIndex: number +): Promise { + let messages: MessageData[] + try { + const response = await client.session.messages({ path: { id: sessionID } }) + messages = normalizeSDKResponse(response, [] as MessageData[], { preferResponseOnMissingData: true }) + } catch { + return null + } + + if (targetIndex < 0 || targetIndex >= messages.length) return null + + const targetMessage = messages[targetIndex] + if (targetMessage.info?.role !== "assistant") return null + if (!targetMessage.info?.id) return null + if (!targetMessage.parts || targetMessage.parts.length === 0) return null + + const partsWithIds = targetMessage.parts.filter( + (part): part is { id: string; type: string } => typeof part.id === "string" + ) + if (partsWithIds.length === 0) return null + + const sortedParts = [...partsWithIds].sort((a, b) => a.id.localeCompare(b.id)) + const firstPart = sortedParts[0] + const firstIsThinking = THINKING_TYPES.has(firstPart.type) + + return firstIsThinking ? null : targetMessage.info.id +} diff --git a/src/hooks/session-recovery/recover-thinking-disabled-violation.ts b/src/hooks/session-recovery/recover-thinking-disabled-violation.ts index 6eeded93..751d9535 100644 --- a/src/hooks/session-recovery/recover-thinking-disabled-violation.ts +++ b/src/hooks/session-recovery/recover-thinking-disabled-violation.ts @@ -1,14 +1,23 @@ import type { createOpencodeClient } from "@opencode-ai/sdk" import type { MessageData } from "./types" import { findMessagesWithThinkingBlocks, stripThinkingParts } from "./storage" +import { isSqliteBackend } from "../../shared/opencode-storage-detection" +import { stripThinkingPartsAsync } from "./storage/thinking-strip" +import { THINKING_TYPES } from "./constants" +import { log } from "../../shared/logger" +import { normalizeSDKResponse } from "../../shared" type Client = ReturnType export async function recoverThinkingDisabledViolation( - _client: Client, + client: Client, sessionID: string, _failedAssistantMsg: MessageData ): Promise { + if (isSqliteBackend()) { + return recoverThinkingDisabledViolationFromSDK(client, sessionID) + } + const messagesWithThinking = findMessagesWithThinkingBlocks(sessionID) if (messagesWithThinking.length === 0) { return false @@ -23,3 +32,44 @@ export async function recoverThinkingDisabledViolation( return anySuccess } + +async function recoverThinkingDisabledViolationFromSDK( + client: Client, + sessionID: string +): Promise { + try { + const response = await client.session.messages({ path: { id: sessionID } }) + const messages = normalizeSDKResponse(response, [] as MessageData[], { preferResponseOnMissingData: true }) + + const messageIDsWithThinking: string[] = [] + for (const msg of messages) { + if (msg.info?.role !== "assistant") continue + if (!msg.info?.id) continue + if (!msg.parts) continue + + const hasThinking = msg.parts.some((part) => THINKING_TYPES.has(part.type)) + if (hasThinking) { + messageIDsWithThinking.push(msg.info.id) + } + } + + if (messageIDsWithThinking.length === 0) { + return false + } + + let anySuccess = false + for (const messageID of messageIDsWithThinking) { + if (await stripThinkingPartsAsync(client, sessionID, messageID)) { + anySuccess = true + } + } + + return anySuccess + } catch (error) { + log("[session-recovery] recoverThinkingDisabledViolationFromSDK failed", { + sessionID, + error: String(error), + }) + return false + } +} diff --git a/src/hooks/session-recovery/recover-tool-result-missing.ts b/src/hooks/session-recovery/recover-tool-result-missing.ts index 1f114fe3..a1121fc6 100644 --- a/src/hooks/session-recovery/recover-tool-result-missing.ts +++ b/src/hooks/session-recovery/recover-tool-result-missing.ts @@ -1,6 +1,8 @@ import type { createOpencodeClient } from "@opencode-ai/sdk" import type { MessageData } from "./types" import { readParts } from "./storage" +import { isSqliteBackend } from "../../shared/opencode-storage-detection" +import { normalizeSDKResponse } from "../../shared" type Client = ReturnType @@ -20,6 +22,26 @@ function extractToolUseIds(parts: MessagePart[]): string[] { return parts.filter((part): part is ToolUsePart => part.type === "tool_use" && !!part.id).map((part) => part.id) } +async function readPartsFromSDKFallback( + client: Client, + sessionID: string, + messageID: string +): Promise { + try { + const response = await client.session.messages({ path: { id: sessionID } }) + const messages = normalizeSDKResponse(response, [] as MessageData[], { preferResponseOnMissingData: true }) + const target = messages.find((m) => m.info?.id === messageID) + if (!target?.parts) return [] + + return target.parts.map((part) => ({ + type: part.type === "tool" ? "tool_use" : part.type, + id: "callID" in part ? (part as { callID?: string }).callID : part.id, + })) + } catch { + return [] + } +} + export async function recoverToolResultMissing( client: Client, sessionID: string, @@ -27,11 +49,15 @@ export async function recoverToolResultMissing( ): Promise { let parts = failedAssistantMsg.parts || [] if (parts.length === 0 && failedAssistantMsg.info?.id) { - const storedParts = readParts(failedAssistantMsg.info.id) - parts = storedParts.map((part) => ({ - type: part.type === "tool" ? "tool_use" : part.type, - id: "callID" in part ? (part as { callID?: string }).callID : part.id, - })) + if (isSqliteBackend()) { + parts = await readPartsFromSDKFallback(client, sessionID, failedAssistantMsg.info.id) + } else { + const storedParts = readParts(failedAssistantMsg.info.id) + parts = storedParts.map((part) => ({ + type: part.type === "tool" ? "tool_use" : part.type, + id: "callID" in part ? (part as { callID?: string }).callID : part.id, + })) + } } const toolUseIds = extractToolUseIds(parts) diff --git a/src/hooks/session-recovery/storage.ts b/src/hooks/session-recovery/storage.ts index b9dbccb9..741569bb 100644 --- a/src/hooks/session-recovery/storage.ts +++ b/src/hooks/session-recovery/storage.ts @@ -1,9 +1,12 @@ export { generatePartId } from "./storage/part-id" export { getMessageDir } from "./storage/message-dir" export { readMessages } from "./storage/messages-reader" +export { readMessagesFromSDK } from "./storage/messages-reader" export { readParts } from "./storage/parts-reader" +export { readPartsFromSDK } from "./storage/parts-reader" export { hasContent, messageHasContent } from "./storage/part-content" export { injectTextPart } from "./storage/text-part-injector" +export { injectTextPartAsync } from "./storage/text-part-injector" export { findEmptyMessages, @@ -11,6 +14,7 @@ export { findFirstEmptyMessage, } from "./storage/empty-messages" export { findMessagesWithEmptyTextParts } from "./storage/empty-text" +export { findMessagesWithEmptyTextPartsFromSDK } from "./storage/empty-text" export { findMessagesWithThinkingBlocks, @@ -24,3 +28,7 @@ export { export { prependThinkingPart } from "./storage/thinking-prepend" export { stripThinkingParts } from "./storage/thinking-strip" export { replaceEmptyTextParts } from "./storage/empty-text" + +export { prependThinkingPartAsync } from "./storage/thinking-prepend" +export { stripThinkingPartsAsync } from "./storage/thinking-strip" +export { replaceEmptyTextPartsAsync } from "./storage/empty-text" diff --git a/src/hooks/session-recovery/storage/empty-text.ts b/src/hooks/session-recovery/storage/empty-text.ts index aa6ff2eb..c9aa3493 100644 --- a/src/hooks/session-recovery/storage/empty-text.ts +++ b/src/hooks/session-recovery/storage/empty-text.ts @@ -1,11 +1,21 @@ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs" import { join } from "node:path" +import type { PluginInput } from "@opencode-ai/plugin" import { PART_STORAGE } from "../constants" -import type { StoredPart, StoredTextPart } from "../types" +import type { StoredPart, StoredTextPart, MessageData } from "../types" import { readMessages } from "./messages-reader" import { readParts } from "./parts-reader" +import { log, isSqliteBackend, patchPart } from "../../../shared" +import { normalizeSDKResponse } from "../../../shared" + +type OpencodeClient = PluginInput["client"] export function replaceEmptyTextParts(messageID: string, replacementText: string): boolean { + if (isSqliteBackend()) { + log("[session-recovery] Disabled on SQLite backend: replaceEmptyTextParts (use async variant)") + return false + } + const partDir = join(PART_STORAGE, messageID) if (!existsSync(partDir)) return false @@ -34,6 +44,38 @@ export function replaceEmptyTextParts(messageID: string, replacementText: string return anyReplaced } +export async function replaceEmptyTextPartsAsync( + client: OpencodeClient, + sessionID: string, + messageID: string, + replacementText: string +): Promise { + try { + const response = await client.session.messages({ path: { id: sessionID } }) + const messages = normalizeSDKResponse(response, [] as MessageData[], { preferResponseOnMissingData: true }) + + const targetMsg = messages.find((m) => m.info?.id === messageID) + if (!targetMsg?.parts) return false + + let anyReplaced = false + for (const part of targetMsg.parts) { + if (part.type === "text" && !part.text?.trim() && part.id) { + const patched = await patchPart(client, sessionID, messageID, part.id, { + ...part, + text: replacementText, + synthetic: true, + }) + if (patched) anyReplaced = true + } + } + + return anyReplaced + } catch (error) { + log("[session-recovery] replaceEmptyTextPartsAsync failed", { error: String(error) }) + return false + } +} + export function findMessagesWithEmptyTextParts(sessionID: string): string[] { const messages = readMessages(sessionID) const result: string[] = [] @@ -53,3 +95,24 @@ export function findMessagesWithEmptyTextParts(sessionID: string): string[] { return result } + +export async function findMessagesWithEmptyTextPartsFromSDK( + client: OpencodeClient, + sessionID: string +): Promise { + try { + const response = await client.session.messages({ path: { id: sessionID } }) + const messages = normalizeSDKResponse(response, [] as MessageData[], { preferResponseOnMissingData: true }) + const result: string[] = [] + + for (const msg of messages) { + if (!msg.parts || !msg.info?.id) continue + const hasEmpty = msg.parts.some((p) => p.type === "text" && !p.text?.trim()) + if (hasEmpty) result.push(msg.info.id) + } + + return result + } catch { + return [] + } +} diff --git a/src/hooks/session-recovery/storage/message-dir.ts b/src/hooks/session-recovery/storage/message-dir.ts index 96f03a27..1a2ecaf0 100644 --- a/src/hooks/session-recovery/storage/message-dir.ts +++ b/src/hooks/session-recovery/storage/message-dir.ts @@ -1,21 +1 @@ -import { existsSync, readdirSync } from "node:fs" -import { join } from "node:path" -import { MESSAGE_STORAGE } from "../constants" - -export function getMessageDir(sessionID: string): string { - if (!existsSync(MESSAGE_STORAGE)) return "" - - const directPath = join(MESSAGE_STORAGE, sessionID) - if (existsSync(directPath)) { - return directPath - } - - for (const dir of readdirSync(MESSAGE_STORAGE)) { - const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) - if (existsSync(sessionPath)) { - return sessionPath - } - } - - return "" -} +export { getMessageDir } from "../../../shared/opencode-message-dir" diff --git a/src/hooks/session-recovery/storage/messages-reader.ts b/src/hooks/session-recovery/storage/messages-reader.ts index ad6c7783..ecedf240 100644 --- a/src/hooks/session-recovery/storage/messages-reader.ts +++ b/src/hooks/session-recovery/storage/messages-reader.ts @@ -1,9 +1,39 @@ import { existsSync, readdirSync, readFileSync } from "node:fs" import { join } from "node:path" +import type { PluginInput } from "@opencode-ai/plugin" import type { StoredMessageMeta } from "../types" import { getMessageDir } from "./message-dir" +import { isSqliteBackend, normalizeSDKResponse } from "../../../shared" +import { isRecord } from "../../../shared/record-type-guard" + +type OpencodeClient = PluginInput["client"] + +function normalizeSDKMessage( + sessionID: string, + value: unknown +): StoredMessageMeta | null { + if (!isRecord(value)) return null + if (typeof value.id !== "string") return null + + const roleValue = value.role + const role: StoredMessageMeta["role"] = roleValue === "assistant" ? "assistant" : "user" + + const created = + isRecord(value.time) && typeof value.time.created === "number" + ? value.time.created + : 0 + + return { + id: value.id, + sessionID, + role, + time: { created }, + } +} export function readMessages(sessionID: string): StoredMessageMeta[] { + if (isSqliteBackend()) return [] + const messageDir = getMessageDir(sessionID) if (!messageDir || !existsSync(messageDir)) return [] @@ -25,3 +55,29 @@ export function readMessages(sessionID: string): StoredMessageMeta[] { return a.id.localeCompare(b.id) }) } + +export async function readMessagesFromSDK( + client: OpencodeClient, + sessionID: string +): Promise { + try { + const response = await client.session.messages({ path: { id: sessionID } }) + const data = normalizeSDKResponse(response, [] as unknown[], { + preferResponseOnMissingData: true, + }) + if (!Array.isArray(data)) return [] + + const messages = data + .map((msg): StoredMessageMeta | null => normalizeSDKMessage(sessionID, msg)) + .filter((msg): msg is StoredMessageMeta => msg !== null) + + return messages.sort((a, b) => { + const aTime = a.time?.created ?? 0 + const bTime = b.time?.created ?? 0 + if (aTime !== bTime) return aTime - bTime + return a.id.localeCompare(b.id) + }) + } catch { + return [] + } +} diff --git a/src/hooks/session-recovery/storage/parts-reader.ts b/src/hooks/session-recovery/storage/parts-reader.ts index c4110a59..287fd7b9 100644 --- a/src/hooks/session-recovery/storage/parts-reader.ts +++ b/src/hooks/session-recovery/storage/parts-reader.ts @@ -1,9 +1,26 @@ import { existsSync, readdirSync, readFileSync } from "node:fs" import { join } from "node:path" +import type { PluginInput } from "@opencode-ai/plugin" import { PART_STORAGE } from "../constants" import type { StoredPart } from "../types" +import { isSqliteBackend } from "../../../shared" +import { isRecord } from "../../../shared/record-type-guard" + +type OpencodeClient = PluginInput["client"] + +function isStoredPart(value: unknown): value is StoredPart { + if (!isRecord(value)) return false + return ( + typeof value.id === "string" && + typeof value.sessionID === "string" && + typeof value.messageID === "string" && + typeof value.type === "string" + ) +} export function readParts(messageID: string): StoredPart[] { + if (isSqliteBackend()) return [] + const partDir = join(PART_STORAGE, messageID) if (!existsSync(partDir)) return [] @@ -20,3 +37,30 @@ export function readParts(messageID: string): StoredPart[] { return parts } + +export async function readPartsFromSDK( + client: OpencodeClient, + sessionID: string, + messageID: string +): Promise { + try { + const response = await client.session.message({ + path: { id: sessionID, messageID }, + }) + + const data: unknown = response.data + if (!isRecord(data)) return [] + + const rawParts = data.parts + if (!Array.isArray(rawParts)) return [] + + return rawParts + .map((part: unknown) => { + if (!isRecord(part) || typeof part.id !== "string" || typeof part.type !== "string") return null + return { ...part, sessionID, messageID } as StoredPart + }) + .filter((part): part is StoredPart => part !== null) + } catch { + return [] + } +} diff --git a/src/hooks/session-recovery/storage/readers-from-sdk.test.ts b/src/hooks/session-recovery/storage/readers-from-sdk.test.ts new file mode 100644 index 00000000..e3194576 --- /dev/null +++ b/src/hooks/session-recovery/storage/readers-from-sdk.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "bun:test" +import { readMessagesFromSDK, readPartsFromSDK } from "../storage" +import { readMessages } from "./messages-reader" +import { readParts } from "./parts-reader" + +function createMockClient(handlers: { + messages?: (sessionID: string) => unknown[] + message?: (sessionID: string, messageID: string) => unknown +}) { + return { + session: { + messages: async (opts: { path: { id: string } }) => { + if (handlers.messages) { + return { data: handlers.messages(opts.path.id) } + } + throw new Error("not implemented") + }, + message: async (opts: { path: { id: string; messageID: string } }) => { + if (handlers.message) { + return { data: handlers.message(opts.path.id, opts.path.messageID) } + } + throw new Error("not implemented") + }, + }, + } as unknown +} + +describe("session-recovery storage SDK readers", () => { + it("readPartsFromSDK returns empty array when fetch fails", async () => { + //#given a client that throws on request + const client = createMockClient({}) as Parameters[0] + + //#when readPartsFromSDK is called + const result = await readPartsFromSDK(client, "ses_test", "msg_test") + + //#then it returns empty array + expect(result).toEqual([]) + }) + + it("readPartsFromSDK returns stored parts from SDK response", async () => { + //#given a client that returns a message with parts + const sessionID = "ses_test" + const messageID = "msg_test" + const storedParts = [ + { id: "prt_1", sessionID, messageID, type: "text", text: "hello" }, + ] + + const client = createMockClient({ + message: (_sid, _mid) => ({ parts: storedParts }), + }) as Parameters[0] + + //#when readPartsFromSDK is called + const result = await readPartsFromSDK(client, sessionID, messageID) + + //#then it returns the parts + expect(result).toEqual(storedParts) + }) + + it("readMessagesFromSDK normalizes and sorts messages", async () => { + //#given a client that returns messages list + const sessionID = "ses_test" + const client = createMockClient({ + messages: () => [ + { id: "msg_b", role: "assistant", time: { created: 2 } }, + { id: "msg_a", role: "user", time: { created: 1 } }, + { id: "msg_c" }, + ], + }) as Parameters[0] + + //#when readMessagesFromSDK is called + const result = await readMessagesFromSDK(client, sessionID) + + //#then it returns sorted StoredMessageMeta with defaults + expect(result).toEqual([ + { id: "msg_c", sessionID, role: "user", time: { created: 0 } }, + { id: "msg_a", sessionID, role: "user", time: { created: 1 } }, + { id: "msg_b", sessionID, role: "assistant", time: { created: 2 } }, + ]) + }) + + it("readParts returns empty array for nonexistent message", () => { + //#given a message ID that has no stored parts + //#when readParts is called + const parts = readParts("msg_nonexistent") + + //#then it returns empty array + expect(parts).toEqual([]) + }) + + it("readMessages returns empty array for nonexistent session", () => { + //#given a session ID that has no stored messages + //#when readMessages is called + const messages = readMessages("ses_nonexistent") + + //#then it returns empty array + expect(messages).toEqual([]) + }) +}) diff --git a/src/hooks/session-recovery/storage/text-part-injector.ts b/src/hooks/session-recovery/storage/text-part-injector.ts index f729ca0f..d20800a9 100644 --- a/src/hooks/session-recovery/storage/text-part-injector.ts +++ b/src/hooks/session-recovery/storage/text-part-injector.ts @@ -1,10 +1,19 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs" import { join } from "node:path" +import type { PluginInput } from "@opencode-ai/plugin" import { PART_STORAGE } from "../constants" import type { StoredTextPart } from "../types" import { generatePartId } from "./part-id" +import { log, isSqliteBackend, patchPart } from "../../../shared" + +type OpencodeClient = PluginInput["client"] export function injectTextPart(sessionID: string, messageID: string, text: string): boolean { + if (isSqliteBackend()) { + log("[session-recovery] Disabled on SQLite backend: injectTextPart (use async variant)") + return false + } + const partDir = join(PART_STORAGE, messageID) if (!existsSync(partDir)) { @@ -28,3 +37,27 @@ export function injectTextPart(sessionID: string, messageID: string, text: strin return false } } + +export async function injectTextPartAsync( + client: OpencodeClient, + sessionID: string, + messageID: string, + text: string +): Promise { + const partId = generatePartId() + const part: Record = { + id: partId, + sessionID, + messageID, + type: "text", + text, + synthetic: true, + } + + try { + return await patchPart(client, sessionID, messageID, partId, part) + } catch (error) { + log("[session-recovery] injectTextPartAsync failed", { error: String(error) }) + return false + } +} diff --git a/src/hooks/session-recovery/storage/thinking-prepend.ts b/src/hooks/session-recovery/storage/thinking-prepend.ts index b8c1bd86..464898c9 100644 --- a/src/hooks/session-recovery/storage/thinking-prepend.ts +++ b/src/hooks/session-recovery/storage/thinking-prepend.ts @@ -1,8 +1,14 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs" import { join } from "node:path" +import type { PluginInput } from "@opencode-ai/plugin" import { PART_STORAGE, THINKING_TYPES } from "../constants" +import type { MessageData } from "../types" import { readMessages } from "./messages-reader" import { readParts } from "./parts-reader" +import { log, isSqliteBackend, patchPart } from "../../../shared" +import { normalizeSDKResponse } from "../../../shared" + +type OpencodeClient = PluginInput["client"] function findLastThinkingContent(sessionID: string, beforeMessageID: string): string { const messages = readMessages(sessionID) @@ -31,6 +37,11 @@ function findLastThinkingContent(sessionID: string, beforeMessageID: string): st } export function prependThinkingPart(sessionID: string, messageID: string): boolean { + if (isSqliteBackend()) { + log("[session-recovery] Disabled on SQLite backend: prependThinkingPart (use async variant)") + return false + } + const partDir = join(PART_STORAGE, messageID) if (!existsSync(partDir)) { @@ -39,7 +50,7 @@ export function prependThinkingPart(sessionID: string, messageID: string): boole const previousThinking = findLastThinkingContent(sessionID, messageID) - const partId = "prt_0000000000_thinking" + const partId = `prt_0000000000_${messageID}_thinking` const part = { id: partId, sessionID, @@ -56,3 +67,58 @@ export function prependThinkingPart(sessionID: string, messageID: string): boole return false } } + +async function findLastThinkingContentFromSDK( + client: OpencodeClient, + sessionID: string, + beforeMessageID: string +): Promise { + try { + const response = await client.session.messages({ path: { id: sessionID } }) + const messages = normalizeSDKResponse(response, [] as MessageData[], { preferResponseOnMissingData: true }) + + const currentIndex = messages.findIndex((m) => m.info?.id === beforeMessageID) + if (currentIndex === -1) return "" + + for (let i = currentIndex - 1; i >= 0; i--) { + const msg = messages[i] + if (msg.info?.role !== "assistant") continue + if (!msg.parts) continue + + for (const part of msg.parts) { + if (part.type && THINKING_TYPES.has(part.type)) { + const content = part.thinking || part.text + if (content && content.trim().length > 0) return content + } + } + } + } catch { + return "" + } + return "" +} + +export async function prependThinkingPartAsync( + client: OpencodeClient, + sessionID: string, + messageID: string +): Promise { + const previousThinking = await findLastThinkingContentFromSDK(client, sessionID, messageID) + + const partId = `prt_0000000000_${messageID}_thinking` + const part: Record = { + id: partId, + sessionID, + messageID, + type: "thinking", + thinking: previousThinking || "[Continuing from previous reasoning]", + synthetic: true, + } + + try { + return await patchPart(client, sessionID, messageID, partId, part) + } catch (error) { + log("[session-recovery] prependThinkingPartAsync failed", { error: String(error) }) + return false + } +} diff --git a/src/hooks/session-recovery/storage/thinking-strip.ts b/src/hooks/session-recovery/storage/thinking-strip.ts index 8731508a..518ef1b0 100644 --- a/src/hooks/session-recovery/storage/thinking-strip.ts +++ b/src/hooks/session-recovery/storage/thinking-strip.ts @@ -1,9 +1,19 @@ import { existsSync, readdirSync, readFileSync, unlinkSync } from "node:fs" import { join } from "node:path" +import type { PluginInput } from "@opencode-ai/plugin" import { PART_STORAGE, THINKING_TYPES } from "../constants" import type { StoredPart } from "../types" +import { log, isSqliteBackend, deletePart } from "../../../shared" +import { normalizeSDKResponse } from "../../../shared" + +type OpencodeClient = PluginInput["client"] export function stripThinkingParts(messageID: string): boolean { + if (isSqliteBackend()) { + log("[session-recovery] Disabled on SQLite backend: stripThinkingParts (use async variant)") + return false + } + const partDir = join(PART_STORAGE, messageID) if (!existsSync(partDir)) return false @@ -25,3 +35,33 @@ export function stripThinkingParts(messageID: string): boolean { return anyRemoved } + +export async function stripThinkingPartsAsync( + client: OpencodeClient, + sessionID: string, + messageID: string +): Promise { + try { + const response = await client.session.messages({ path: { id: sessionID } }) + const messages = normalizeSDKResponse(response, [] as Array<{ parts?: Array<{ type: string; id: string }> }>, { preferResponseOnMissingData: true }) + + const targetMsg = messages.find((m) => { + const info = (m as Record)["info"] as Record | undefined + return info?.["id"] === messageID + }) + if (!targetMsg?.parts) return false + + let anyRemoved = false + for (const part of targetMsg.parts) { + if (THINKING_TYPES.has(part.type) && part.id) { + const deleted = await deletePart(client, sessionID, messageID, part.id) + if (deleted) anyRemoved = true + } + } + + return anyRemoved + } catch (error) { + log("[session-recovery] stripThinkingPartsAsync failed", { error: String(error) }) + return false + } +} diff --git a/src/hooks/session-todo-status.ts b/src/hooks/session-todo-status.ts index cb2a28f2..c86752fe 100644 --- a/src/hooks/session-todo-status.ts +++ b/src/hooks/session-todo-status.ts @@ -1,4 +1,5 @@ import type { PluginInput } from "@opencode-ai/plugin" +import { normalizeSDKResponse } from "../shared" interface Todo { content: string @@ -10,7 +11,7 @@ interface Todo { export async function hasIncompleteTodos(ctx: PluginInput, sessionID: string): Promise { try { const response = await ctx.client.session.todo({ path: { id: sessionID } }) - const todos = (response.data ?? response) as Todo[] + const todos = normalizeSDKResponse(response, [] as Todo[], { preferResponseOnMissingData: true }) if (!todos || todos.length === 0) return false return todos.some((todo) => todo.status !== "completed" && todo.status !== "cancelled") } catch { diff --git a/src/hooks/sisyphus-junior-notepad/hook.ts b/src/hooks/sisyphus-junior-notepad/hook.ts index f80c0df0..28a284e6 100644 --- a/src/hooks/sisyphus-junior-notepad/hook.ts +++ b/src/hooks/sisyphus-junior-notepad/hook.ts @@ -5,7 +5,7 @@ import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive" import { log } from "../../shared/logger" import { HOOK_NAME, NOTEPAD_DIRECTIVE } from "./constants" -export function createSisyphusJuniorNotepadHook(_ctx: PluginInput) { +export function createSisyphusJuniorNotepadHook(ctx: PluginInput) { return { "tool.execute.before": async ( input: { tool: string; sessionID: string; callID: string }, @@ -17,7 +17,7 @@ export function createSisyphusJuniorNotepadHook(_ctx: PluginInput) { } // 2. Check if caller is Atlas (orchestrator) - if (!isCallerOrchestrator(input.sessionID)) { + if (!(await isCallerOrchestrator(input.sessionID, ctx.client))) { return } diff --git a/src/hooks/todo-continuation-enforcer/constants.ts b/src/hooks/todo-continuation-enforcer/constants.ts index b57a8164..db4d7b1c 100644 --- a/src/hooks/todo-continuation-enforcer/constants.ts +++ b/src/hooks/todo-continuation-enforcer/constants.ts @@ -18,3 +18,5 @@ export const COUNTDOWN_GRACE_PERIOD_MS = 500 export const ABORT_WINDOW_MS = 3000 export const CONTINUATION_COOLDOWN_MS = 30_000 +export const MAX_CONSECUTIVE_FAILURES = 5 +export const FAILURE_RESET_WINDOW_MS = 5 * 60 * 1000 diff --git a/src/hooks/todo-continuation-enforcer/continuation-injection.ts b/src/hooks/todo-continuation-enforcer/continuation-injection.ts index 2c67fa78..a8e8586e 100644 --- a/src/hooks/todo-continuation-enforcer/continuation-injection.ts +++ b/src/hooks/todo-continuation-enforcer/continuation-injection.ts @@ -1,11 +1,15 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { BackgroundManager } from "../../features/background-agent" +import { normalizeSDKResponse } from "../../shared" import { findNearestMessageWithFields, + findNearestMessageWithFieldsFromSDK, type ToolPermission, } from "../../features/hook-message-injector" import { log } from "../../shared/logger" +import { isSqliteBackend } from "../../shared/opencode-storage-detection" +import { getAgentConfigKey } from "../../shared/agent-display-names" import { CONTINUATION_PROMPT, @@ -61,7 +65,7 @@ export async function injectContinuation(args: { let todos: Todo[] = [] try { const response = await ctx.client.session.todo({ path: { id: sessionID } }) - todos = (response.data ?? response) as Todo[] + todos = normalizeSDKResponse(response, [] as Todo[], { preferResponseOnMissingData: true }) } catch (error) { log(`[${HOOK_NAME}] Failed to fetch todos`, { sessionID, error: String(error) }) return @@ -78,8 +82,13 @@ export async function injectContinuation(args: { let tools = resolvedInfo?.tools if (!agentName || !model) { - const messageDir = getMessageDir(sessionID) - const previousMessage = messageDir ? findNearestMessageWithFields(messageDir) : null + let previousMessage = null + if (isSqliteBackend()) { + previousMessage = await findNearestMessageWithFieldsFromSDK(ctx.client, sessionID) + } else { + const messageDir = getMessageDir(sessionID) + previousMessage = messageDir ? findNearestMessageWithFields(messageDir) : null + } agentName = agentName ?? previousMessage?.agent model = model ?? @@ -95,7 +104,7 @@ export async function injectContinuation(args: { tools = tools ?? previousMessage?.tools } - if (agentName && skipAgents.includes(agentName)) { + if (agentName && skipAgents.some(s => getAgentConfigKey(s) === getAgentConfigKey(agentName))) { log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: agentName }) return } @@ -141,11 +150,14 @@ ${todoList}` if (injectionState) { injectionState.inFlight = false injectionState.lastInjectedAt = Date.now() + injectionState.consecutiveFailures = 0 } } catch (error) { log(`[${HOOK_NAME}] Injection failed`, { sessionID, error: String(error) }) if (injectionState) { injectionState.inFlight = false + injectionState.lastInjectedAt = Date.now() + injectionState.consecutiveFailures = (injectionState.consecutiveFailures ?? 0) + 1 } } } diff --git a/src/hooks/todo-continuation-enforcer/idle-event.ts b/src/hooks/todo-continuation-enforcer/idle-event.ts index 2bfb96bf..689672c0 100644 --- a/src/hooks/todo-continuation-enforcer/idle-event.ts +++ b/src/hooks/todo-continuation-enforcer/idle-event.ts @@ -2,13 +2,17 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { BackgroundManager } from "../../features/background-agent" import type { ToolPermission } from "../../features/hook-message-injector" +import { normalizeSDKResponse } from "../../shared" import { log } from "../../shared/logger" +import { getAgentConfigKey } from "../../shared/agent-display-names" import { ABORT_WINDOW_MS, CONTINUATION_COOLDOWN_MS, DEFAULT_SKIP_AGENTS, + FAILURE_RESET_WINDOW_MS, HOOK_NAME, + MAX_CONSECUTIVE_FAILURES, } from "./constants" import { isLastAssistantMessageAborted } from "./abort-detection" import { getIncompleteCount } from "./todo" @@ -65,7 +69,7 @@ export async function handleSessionIdle(args: { path: { id: sessionID }, query: { directory: ctx.directory }, }) - const messages = (messagesResp as { data?: Array<{ info?: MessageInfo }> }).data ?? [] + const messages = normalizeSDKResponse(messagesResp, [] as Array<{ info?: MessageInfo }>) if (isLastAssistantMessageAborted(messages)) { log(`[${HOOK_NAME}] Skipped: last assistant message was aborted (API fallback)`, { sessionID }) return @@ -77,7 +81,7 @@ export async function handleSessionIdle(args: { let todos: Todo[] = [] try { const response = await ctx.client.session.todo({ path: { id: sessionID } }) - todos = (response.data ?? response) as Todo[] + todos = normalizeSDKResponse(response, [] as Todo[], { preferResponseOnMissingData: true }) } catch (error) { log(`[${HOOK_NAME}] Todo fetch failed`, { sessionID, error: String(error) }) return @@ -99,8 +103,35 @@ export async function handleSessionIdle(args: { return } - if (state.lastInjectedAt && Date.now() - state.lastInjectedAt < CONTINUATION_COOLDOWN_MS) { - log(`[${HOOK_NAME}] Skipped: cooldown active`, { sessionID }) + if ( + state.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES + && state.lastInjectedAt + && Date.now() - state.lastInjectedAt >= FAILURE_RESET_WINDOW_MS + ) { + state.consecutiveFailures = 0 + log(`[${HOOK_NAME}] Reset consecutive failures after recovery window`, { + sessionID, + failureResetWindowMs: FAILURE_RESET_WINDOW_MS, + }) + } + + if (state.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + log(`[${HOOK_NAME}] Skipped: max consecutive failures reached`, { + sessionID, + consecutiveFailures: state.consecutiveFailures, + maxConsecutiveFailures: MAX_CONSECUTIVE_FAILURES, + }) + return + } + + const effectiveCooldown = + CONTINUATION_COOLDOWN_MS * Math.pow(2, Math.min(state.consecutiveFailures, 5)) + if (state.lastInjectedAt && Date.now() - state.lastInjectedAt < effectiveCooldown) { + log(`[${HOOK_NAME}] Skipped: cooldown active`, { + sessionID, + effectiveCooldown, + consecutiveFailures: state.consecutiveFailures, + }) return } @@ -110,7 +141,7 @@ export async function handleSessionIdle(args: { const messagesResp = await ctx.client.session.messages({ path: { id: sessionID }, }) - const messages = (messagesResp.data ?? []) as Array<{ info?: MessageInfo }> + const messages = normalizeSDKResponse(messagesResp, [] as Array<{ info?: MessageInfo }>) for (let i = messages.length - 1; i >= 0; i--) { const info = messages[i].info if (info?.agent === "compaction") { @@ -132,8 +163,9 @@ export async function handleSessionIdle(args: { log(`[${HOOK_NAME}] Agent check`, { sessionID, agentName: resolvedInfo?.agent, skipAgents, hasCompactionMessage }) - if (resolvedInfo?.agent && skipAgents.includes(resolvedInfo.agent)) { - log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: resolvedInfo.agent }) + const resolvedAgentName = resolvedInfo?.agent + if (resolvedAgentName && skipAgents.some(s => getAgentConfigKey(s) === getAgentConfigKey(resolvedAgentName))) { + log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: resolvedAgentName }) return } if (hasCompactionMessage && !resolvedInfo?.agent) { diff --git a/src/hooks/todo-continuation-enforcer/message-directory.ts b/src/hooks/todo-continuation-enforcer/message-directory.ts index 85e68242..a9111f43 100644 --- a/src/hooks/todo-continuation-enforcer/message-directory.ts +++ b/src/hooks/todo-continuation-enforcer/message-directory.ts @@ -1,18 +1 @@ -import { existsSync, readdirSync } from "node:fs" -import { join } from "node:path" - -import { MESSAGE_STORAGE } from "../../features/hook-message-injector" - -export function getMessageDir(sessionID: string): string | null { - if (!existsSync(MESSAGE_STORAGE)) return null - - const directPath = join(MESSAGE_STORAGE, sessionID) - if (existsSync(directPath)) return directPath - - for (const dir of readdirSync(MESSAGE_STORAGE)) { - const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) - if (existsSync(sessionPath)) return sessionPath - } - - return null -} +export { getMessageDir } from "../../shared/opencode-message-dir" diff --git a/src/hooks/todo-continuation-enforcer/session-state.ts b/src/hooks/todo-continuation-enforcer/session-state.ts index 16cbb782..a02a5e5a 100644 --- a/src/hooks/todo-continuation-enforcer/session-state.ts +++ b/src/hooks/todo-continuation-enforcer/session-state.ts @@ -45,7 +45,9 @@ export function createSessionStateStore(): SessionStateStore { return existing.state } - const state: SessionState = {} + const state: SessionState = { + consecutiveFailures: 0, + } sessions.set(sessionID, { state, lastAccessedAt: Date.now() }) return state } diff --git a/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts b/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts index 52343fb3..18a2aad6 100644 --- a/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts +++ b/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts @@ -4,7 +4,11 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test" import type { BackgroundManager } from "../../features/background-agent" import { setMainSession, subagentSessions, _resetForTesting } from "../../features/claude-code-session-state" import { createTodoContinuationEnforcer } from "." -import { CONTINUATION_COOLDOWN_MS } from "./constants" +import { + CONTINUATION_COOLDOWN_MS, + FAILURE_RESET_WINDOW_MS, + MAX_CONSECUTIVE_FAILURES, +} from "./constants" type TimerCallback = (...args: any[]) => void @@ -164,6 +168,15 @@ describe("todo-continuation-enforcer", () => { } } + interface PromptRequestOptions { + path: { id: string } + body: { + agent?: string + model?: { providerID?: string; modelID?: string } + parts: Array<{ text: string }> + } + } + let mockMessages: MockMessage[] = [] function createMockPluginInput() { @@ -551,6 +564,164 @@ describe("todo-continuation-enforcer", () => { expect(promptCalls).toHaveLength(2) }, { timeout: 15000 }) + test("should apply cooldown even after injection failure", async () => { + //#given + const sessionID = "main-failure-cooldown" + setMainSession(sessionID) + const mockInput = createMockPluginInput() + mockInput.client.session.promptAsync = async (opts: PromptRequestOptions) => { + promptCalls.push({ + sessionID: opts.path.id, + agent: opts.body.agent, + model: opts.body.model, + text: opts.body.parts[0].text, + }) + throw new Error("simulated auth failure") + } + const hook = createTodoContinuationEnforcer(mockInput, {}) + + //#when + await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) + await fakeTimers.advanceBy(2500, true) + await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) + await fakeTimers.advanceBy(2500, true) + + //#then + expect(promptCalls).toHaveLength(1) + }) + + test("should stop retries after max consecutive failures", async () => { + //#given + const sessionID = "main-max-consecutive-failures" + setMainSession(sessionID) + const mockInput = createMockPluginInput() + mockInput.client.session.promptAsync = async (opts: PromptRequestOptions) => { + promptCalls.push({ + sessionID: opts.path.id, + agent: opts.body.agent, + model: opts.body.model, + text: opts.body.parts[0].text, + }) + throw new Error("simulated auth failure") + } + const hook = createTodoContinuationEnforcer(mockInput, {}) + + //#when + for (let index = 0; index < MAX_CONSECUTIVE_FAILURES; index++) { + await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) + await fakeTimers.advanceBy(2500, true) + if (index < MAX_CONSECUTIVE_FAILURES - 1) { + await fakeTimers.advanceClockBy(1_000_000) + } + } + await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) + await fakeTimers.advanceBy(2500, true) + + //#then + expect(promptCalls).toHaveLength(MAX_CONSECUTIVE_FAILURES) + }, { timeout: 30000 }) + + test("should resume retries after reset window when max failures reached", async () => { + //#given + const sessionID = "main-recovery-after-max-failures" + setMainSession(sessionID) + const mockInput = createMockPluginInput() + mockInput.client.session.promptAsync = async (opts: PromptRequestOptions) => { + promptCalls.push({ + sessionID: opts.path.id, + agent: opts.body.agent, + model: opts.body.model, + text: opts.body.parts[0].text, + }) + throw new Error("simulated auth failure") + } + const hook = createTodoContinuationEnforcer(mockInput, {}) + + //#when + for (let index = 0; index < MAX_CONSECUTIVE_FAILURES; index++) { + await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) + await fakeTimers.advanceBy(2500, true) + if (index < MAX_CONSECUTIVE_FAILURES - 1) { + await fakeTimers.advanceClockBy(1_000_000) + } + } + + await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) + await fakeTimers.advanceBy(2500, true) + + await fakeTimers.advanceClockBy(FAILURE_RESET_WINDOW_MS) + await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) + await fakeTimers.advanceBy(2500, true) + + //#then + expect(promptCalls).toHaveLength(MAX_CONSECUTIVE_FAILURES + 1) + }, { timeout: 30000 }) + + test("should increase cooldown exponentially after consecutive failures", async () => { + //#given + const sessionID = "main-exponential-backoff" + setMainSession(sessionID) + const mockInput = createMockPluginInput() + mockInput.client.session.promptAsync = async (opts: PromptRequestOptions) => { + promptCalls.push({ + sessionID: opts.path.id, + agent: opts.body.agent, + model: opts.body.model, + text: opts.body.parts[0].text, + }) + throw new Error("simulated auth failure") + } + const hook = createTodoContinuationEnforcer(mockInput, {}) + + //#when + await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) + await fakeTimers.advanceBy(2500, true) + await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS) + await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) + await fakeTimers.advanceBy(2500, true) + await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS) + await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) + await fakeTimers.advanceBy(2500, true) + + //#then + expect(promptCalls).toHaveLength(2) + }, { timeout: 30000 }) + + test("should reset consecutive failure count after successful injection", async () => { + //#given + const sessionID = "main-reset-consecutive-failures" + setMainSession(sessionID) + let shouldFail = true + const mockInput = createMockPluginInput() + mockInput.client.session.promptAsync = async (opts: PromptRequestOptions) => { + promptCalls.push({ + sessionID: opts.path.id, + agent: opts.body.agent, + model: opts.body.model, + text: opts.body.parts[0].text, + }) + if (shouldFail) { + shouldFail = false + throw new Error("simulated auth failure") + } + return {} + } + const hook = createTodoContinuationEnforcer(mockInput, {}) + + //#when + await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) + await fakeTimers.advanceBy(2500, true) + await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS * 2) + await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) + await fakeTimers.advanceBy(2500, true) + await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS) + await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) + await fakeTimers.advanceBy(2500, true) + + //#then + expect(promptCalls).toHaveLength(3) + }, { timeout: 30000 }) + test("should keep injecting even when todos remain unchanged across cycles", async () => { //#given const sessionID = "main-no-stagnation-cap" diff --git a/src/hooks/todo-continuation-enforcer/types.ts b/src/hooks/todo-continuation-enforcer/types.ts index 7d702b0e..20c28d6f 100644 --- a/src/hooks/todo-continuation-enforcer/types.ts +++ b/src/hooks/todo-continuation-enforcer/types.ts @@ -15,10 +15,10 @@ export interface TodoContinuationEnforcer { } export interface Todo { - content: string - status: string - priority: string - id: string + content: string; + status: string; + priority: string; + id?: string; } export interface SessionState { @@ -29,6 +29,7 @@ export interface SessionState { abortDetectedAt?: number lastInjectedAt?: number inFlight?: boolean + consecutiveFailures: number } export interface MessageInfo { diff --git a/src/plugin-handlers/AGENTS.md b/src/plugin-handlers/AGENTS.md index 5b3af3e0..b8288e33 100644 --- a/src/plugin-handlers/AGENTS.md +++ b/src/plugin-handlers/AGENTS.md @@ -2,7 +2,7 @@ ## OVERVIEW -Configuration orchestration layer. Runs once at plugin init — transforms raw OpenCode config into resolved agent/tool/permission structures. +Configuration orchestration layer. Runs once at plugin init — transforms raw OpenCode config into resolved agent/tool/permission structures via 6-phase sequential loading. ## STRUCTURE ``` diff --git a/src/plugin-handlers/agent-config-handler.ts b/src/plugin-handlers/agent-config-handler.ts index c2c993ba..101300a6 100644 --- a/src/plugin-handlers/agent-config-handler.ts +++ b/src/plugin-handlers/agent-config-handler.ts @@ -3,6 +3,7 @@ import { createSisyphusJuniorAgentWithOverrides } from "../agents/sisyphus-junio import type { OhMyOpenCodeConfig } from "../config"; import { log, migrateAgentConfig } from "../shared"; import { AGENT_NAME_MAP } from "../shared/migration"; +import { getAgentDisplayName } from "../shared/agent-display-names"; import { discoverConfigSourceSkills, discoverOpencodeGlobalSkills, @@ -13,6 +14,7 @@ import { import { loadProjectAgents, loadUserAgents } from "../features/claude-code-agent-loader"; import type { PluginComponents } from "./plugin-components-loader"; import { reorderAgentsByPriority } from "./agent-priority-order"; +import { remapAgentKeysToDisplayNames } from "./agent-key-remapper"; import { buildPrometheusAgentConfig } from "./prometheus-agent-config-builder"; import { buildPlanDemoteConfig } from "./plan-model-inheritance"; @@ -104,7 +106,7 @@ export async function applyAgentConfig(params: { const configAgent = params.config.agent as AgentConfigRecord | undefined; if (isSisyphusEnabled && builtinAgents.sisyphus) { - (params.config as { default_agent?: string }).default_agent = "sisyphus"; + (params.config as { default_agent?: string }).default_agent = getAgentDisplayName("sisyphus"); const agentConfig: Record = { sisyphus: builtinAgents.sisyphus, @@ -193,6 +195,9 @@ export async function applyAgentConfig(params: { } if (params.config.agent) { + params.config.agent = remapAgentKeysToDisplayNames( + params.config.agent as Record, + ); params.config.agent = reorderAgentsByPriority( params.config.agent as Record, ); diff --git a/src/plugin-handlers/agent-key-remapper.test.ts b/src/plugin-handlers/agent-key-remapper.test.ts new file mode 100644 index 00000000..fe78ea73 --- /dev/null +++ b/src/plugin-handlers/agent-key-remapper.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from "bun:test" +import { remapAgentKeysToDisplayNames } from "./agent-key-remapper" + +describe("remapAgentKeysToDisplayNames", () => { + it("remaps known agent keys to display names", () => { + // given agents with lowercase keys + const agents = { + sisyphus: { prompt: "test", mode: "primary" }, + oracle: { prompt: "test", mode: "subagent" }, + } + + // when remapping + const result = remapAgentKeysToDisplayNames(agents) + + // then known agents get display name keys + expect(result["Sisyphus (Ultraworker)"]).toBeDefined() + expect(result["oracle"]).toBeDefined() + expect(result["sisyphus"]).toBeUndefined() + }) + + it("preserves unknown agent keys unchanged", () => { + // given agents with a custom key + const agents = { + "custom-agent": { prompt: "custom" }, + } + + // when remapping + const result = remapAgentKeysToDisplayNames(agents) + + // then custom key is unchanged + expect(result["custom-agent"]).toBeDefined() + }) + + it("remaps all core agents", () => { + // given all core agents + const agents = { + sisyphus: {}, + hephaestus: {}, + prometheus: {}, + atlas: {}, + metis: {}, + momus: {}, + "sisyphus-junior": {}, + } + + // when remapping + const result = remapAgentKeysToDisplayNames(agents) + + // then all get display name keys + expect(Object.keys(result)).toEqual([ + "Sisyphus (Ultraworker)", + "Hephaestus (Deep Agent)", + "Prometheus (Plan Builder)", + "Atlas (Plan Executor)", + "Metis (Plan Consultant)", + "Momus (Plan Critic)", + "Sisyphus-Junior", + ]) + }) +}) diff --git a/src/plugin-handlers/agent-key-remapper.ts b/src/plugin-handlers/agent-key-remapper.ts new file mode 100644 index 00000000..dd2a127e --- /dev/null +++ b/src/plugin-handlers/agent-key-remapper.ts @@ -0,0 +1,18 @@ +import { AGENT_DISPLAY_NAMES } from "../shared/agent-display-names" + +export function remapAgentKeysToDisplayNames( + agents: Record, +): Record { + const result: Record = {} + + for (const [key, value] of Object.entries(agents)) { + const displayName = AGENT_DISPLAY_NAMES[key] + if (displayName && displayName !== key) { + result[displayName] = value + } else { + result[key] = value + } + } + + return result +} diff --git a/src/plugin-handlers/agent-priority-order.ts b/src/plugin-handlers/agent-priority-order.ts index a87c0199..9ca88613 100644 --- a/src/plugin-handlers/agent-priority-order.ts +++ b/src/plugin-handlers/agent-priority-order.ts @@ -1,4 +1,11 @@ -const CORE_AGENT_ORDER = ["sisyphus", "hephaestus", "prometheus", "atlas"] as const; +import { getAgentDisplayName } from "../shared/agent-display-names"; + +const CORE_AGENT_ORDER = [ + getAgentDisplayName("sisyphus"), + getAgentDisplayName("hephaestus"), + getAgentDisplayName("prometheus"), + getAgentDisplayName("atlas"), +] as const; export function reorderAgentsByPriority( agents: Record, diff --git a/src/plugin-handlers/config-handler.test.ts b/src/plugin-handlers/config-handler.test.ts index bca4ce4d..cf6e2461 100644 --- a/src/plugin-handlers/config-handler.test.ts +++ b/src/plugin-handlers/config-handler.test.ts @@ -4,6 +4,7 @@ import { describe, test, expect, spyOn, beforeEach, afterEach } from "bun:test" import { resolveCategoryConfig, createConfigHandler } from "./config-handler" import type { CategoryConfig } from "../config/schema" import type { OhMyOpenCodeConfig } from "../config" +import { getAgentDisplayName } from "../shared/agent-display-names" import * as agents from "../agents" import * as sisyphusJunior from "../agents/sisyphus-junior" @@ -123,7 +124,7 @@ describe("Sisyphus-Junior model inheritance", () => { // #then const agentConfig = config.agent as Record - expect(agentConfig["sisyphus-junior"]?.model).toBe( + expect(agentConfig[getAgentDisplayName("sisyphus-junior")]?.model).toBe( sisyphusJunior.SISYPHUS_JUNIOR_DEFAULTS.model ) }) @@ -155,7 +156,7 @@ describe("Sisyphus-Junior model inheritance", () => { // #then const agentConfig = config.agent as Record - expect(agentConfig["sisyphus-junior"]?.model).toBe( + expect(agentConfig[getAgentDisplayName("sisyphus-junior")]?.model).toBe( "openai/gpt-5.3-codex" ) }) @@ -196,7 +197,12 @@ describe("Plan agent demote behavior", () => { // #then const keys = Object.keys(config.agent as Record) - const coreAgents = ["sisyphus", "hephaestus", "prometheus", "atlas"] + const coreAgents = [ + getAgentDisplayName("sisyphus"), + getAgentDisplayName("hephaestus"), + getAgentDisplayName("prometheus"), + getAgentDisplayName("atlas"), + ] const ordered = keys.filter((key) => coreAgents.includes(key)) expect(ordered).toEqual(coreAgents) }) @@ -236,7 +242,7 @@ describe("Plan agent demote behavior", () => { expect(agents.plan).toBeDefined() expect(agents.plan.mode).toBe("subagent") expect(agents.plan.prompt).toBeUndefined() - expect(agents.prometheus?.prompt).toBeDefined() + expect(agents[getAgentDisplayName("prometheus")]?.prompt).toBeDefined() }) test("plan agent remains unchanged when planner is disabled", async () => { @@ -270,7 +276,7 @@ describe("Plan agent demote behavior", () => { // #then - plan is not touched, prometheus is not created const agents = config.agent as Record - expect(agents.prometheus).toBeUndefined() + expect(agents[getAgentDisplayName("prometheus")]).toBeUndefined() expect(agents.plan).toBeDefined() expect(agents.plan.mode).toBe("primary") expect(agents.plan.prompt).toBe("original plan prompt") @@ -301,8 +307,9 @@ describe("Plan agent demote behavior", () => { // then const agents = config.agent as Record - expect(agents.prometheus).toBeDefined() - expect(agents.prometheus.mode).toBe("all") + const prometheusKey = getAgentDisplayName("prometheus") + expect(agents[prometheusKey]).toBeDefined() + expect(agents[prometheusKey].mode).toBe("all") }) }) @@ -336,8 +343,9 @@ describe("Agent permission defaults", () => { // #then const agentConfig = config.agent as Record }> - expect(agentConfig.hephaestus).toBeDefined() - expect(agentConfig.hephaestus.permission?.task).toBe("allow") + const hephaestusKey = getAgentDisplayName("hephaestus") + expect(agentConfig[hephaestusKey]).toBeDefined() + expect(agentConfig[hephaestusKey].permission?.task).toBe("allow") }) }) @@ -479,8 +487,9 @@ describe("Prometheus direct override priority over category", () => { // then - direct override's reasoningEffort wins const agents = config.agent as Record - expect(agents.prometheus).toBeDefined() - expect(agents.prometheus.reasoningEffort).toBe("low") + const pKey = getAgentDisplayName("prometheus") + expect(agents[pKey]).toBeDefined() + expect(agents[pKey].reasoningEffort).toBe("low") }) test("category reasoningEffort applied when no direct override", async () => { @@ -519,8 +528,9 @@ describe("Prometheus direct override priority over category", () => { // then - category's reasoningEffort is applied const agents = config.agent as Record - expect(agents.prometheus).toBeDefined() - expect(agents.prometheus.reasoningEffort).toBe("high") + const pKey = getAgentDisplayName("prometheus") + expect(agents[pKey]).toBeDefined() + expect(agents[pKey].reasoningEffort).toBe("high") }) test("direct temperature takes priority over category temperature", async () => { @@ -560,8 +570,9 @@ describe("Prometheus direct override priority over category", () => { // then - direct temperature wins over category const agents = config.agent as Record - expect(agents.prometheus).toBeDefined() - expect(agents.prometheus.temperature).toBe(0.1) + const pKey = getAgentDisplayName("prometheus") + expect(agents[pKey]).toBeDefined() + expect(agents[pKey].temperature).toBe(0.1) }) test("prometheus prompt_append is appended to base prompt", async () => { @@ -595,10 +606,11 @@ describe("Prometheus direct override priority over category", () => { // #then - prompt_append is appended to base prompt, not overwriting it const agents = config.agent as Record - expect(agents.prometheus).toBeDefined() - expect(agents.prometheus.prompt).toContain("Prometheus") - expect(agents.prometheus.prompt).toContain(customInstructions) - expect(agents.prometheus.prompt!.endsWith(customInstructions)).toBe(true) + const pKey = getAgentDisplayName("prometheus") + expect(agents[pKey]).toBeDefined() + expect(agents[pKey].prompt).toContain("Prometheus") + expect(agents[pKey].prompt).toContain(customInstructions) + expect(agents[pKey].prompt!.endsWith(customInstructions)).toBe(true) }) }) @@ -947,7 +959,13 @@ describe("config-handler plugin loading error boundary (#1559)", () => { }) describe("per-agent todowrite/todoread deny when task_system enabled", () => { - const PRIMARY_AGENTS = ["sisyphus", "hephaestus", "atlas", "prometheus", "sisyphus-junior"] + const PRIMARY_AGENTS = [ + getAgentDisplayName("sisyphus"), + getAgentDisplayName("hephaestus"), + getAgentDisplayName("atlas"), + getAgentDisplayName("prometheus"), + getAgentDisplayName("sisyphus-junior"), + ] test("denies todowrite and todoread for primary agents when task_system is enabled", async () => { //#given @@ -1021,10 +1039,10 @@ describe("per-agent todowrite/todoread deny when task_system enabled", () => { //#then const agentResult = config.agent as Record }> - expect(agentResult.sisyphus?.permission?.todowrite).toBeUndefined() - expect(agentResult.sisyphus?.permission?.todoread).toBeUndefined() - expect(agentResult.hephaestus?.permission?.todowrite).toBeUndefined() - expect(agentResult.hephaestus?.permission?.todoread).toBeUndefined() + expect(agentResult[getAgentDisplayName("sisyphus")]?.permission?.todowrite).toBeUndefined() + expect(agentResult[getAgentDisplayName("sisyphus")]?.permission?.todoread).toBeUndefined() + expect(agentResult[getAgentDisplayName("hephaestus")]?.permission?.todowrite).toBeUndefined() + expect(agentResult[getAgentDisplayName("hephaestus")]?.permission?.todoread).toBeUndefined() }) test("does not deny todowrite/todoread when task_system is undefined", async () => { @@ -1055,7 +1073,7 @@ describe("per-agent todowrite/todoread deny when task_system enabled", () => { //#then const agentResult = config.agent as Record }> - expect(agentResult.sisyphus?.permission?.todowrite).toBeUndefined() - expect(agentResult.sisyphus?.permission?.todoread).toBeUndefined() + expect(agentResult[getAgentDisplayName("sisyphus")]?.permission?.todowrite).toBeUndefined() + expect(agentResult[getAgentDisplayName("sisyphus")]?.permission?.todoread).toBeUndefined() }) }) diff --git a/src/plugin-handlers/prometheus-agent-config-builder.ts b/src/plugin-handlers/prometheus-agent-config-builder.ts index fa6c12a4..54fa0ddc 100644 --- a/src/plugin-handlers/prometheus-agent-config-builder.ts +++ b/src/plugin-handlers/prometheus-agent-config-builder.ts @@ -66,7 +66,6 @@ export async function buildPrometheusAgentConfig(params: { params.pluginPrometheusOverride?.maxTokens ?? categoryConfig?.maxTokens; const base: Record = { - name: "prometheus", ...(resolvedModel ? { model: resolvedModel } : {}), ...(variantToUse ? { variant: variantToUse } : {}), mode: "all", diff --git a/src/plugin-handlers/tool-config-handler.ts b/src/plugin-handlers/tool-config-handler.ts index d587bc97..1e0cdac9 100644 --- a/src/plugin-handlers/tool-config-handler.ts +++ b/src/plugin-handlers/tool-config-handler.ts @@ -1,7 +1,12 @@ import type { OhMyOpenCodeConfig } from "../config"; +import { getAgentDisplayName } from "../shared/agent-display-names"; type AgentWithPermission = { permission?: Record }; +function agentByKey(agentResult: Record, key: string): AgentWithPermission | undefined { + return agentResult[getAgentDisplayName(key)] as AgentWithPermission | undefined; +} + export function applyToolConfig(params: { config: Record; pluginConfig: OhMyOpenCodeConfig; @@ -27,18 +32,18 @@ export function applyToolConfig(params: { const isCliRunMode = process.env.OPENCODE_CLI_RUN_MODE === "true"; const questionPermission = isCliRunMode ? "deny" : "allow"; - if (params.agentResult.librarian) { - const agent = params.agentResult.librarian as AgentWithPermission; - agent.permission = { ...agent.permission, "grep_app_*": "allow" }; + const librarian = agentByKey(params.agentResult, "librarian"); + if (librarian) { + librarian.permission = { ...librarian.permission, "grep_app_*": "allow" }; } - if (params.agentResult["multimodal-looker"]) { - const agent = params.agentResult["multimodal-looker"] as AgentWithPermission; - agent.permission = { ...agent.permission, task: "deny", look_at: "deny" }; + const looker = agentByKey(params.agentResult, "multimodal-looker"); + if (looker) { + looker.permission = { ...looker.permission, task: "deny", look_at: "deny" }; } - if (params.agentResult["atlas"]) { - const agent = params.agentResult["atlas"] as AgentWithPermission; - agent.permission = { - ...agent.permission, + const atlas = agentByKey(params.agentResult, "atlas"); + if (atlas) { + atlas.permission = { + ...atlas.permission, task: "allow", call_omo_agent: "deny", "task_*": "allow", @@ -46,10 +51,10 @@ export function applyToolConfig(params: { ...denyTodoTools, }; } - if (params.agentResult.sisyphus) { - const agent = params.agentResult.sisyphus as AgentWithPermission; - agent.permission = { - ...agent.permission, + const sisyphus = agentByKey(params.agentResult, "sisyphus"); + if (sisyphus) { + sisyphus.permission = { + ...sisyphus.permission, call_omo_agent: "deny", task: "allow", question: questionPermission, @@ -58,20 +63,20 @@ export function applyToolConfig(params: { ...denyTodoTools, }; } - if (params.agentResult.hephaestus) { - const agent = params.agentResult.hephaestus as AgentWithPermission; - agent.permission = { - ...agent.permission, + const hephaestus = agentByKey(params.agentResult, "hephaestus"); + if (hephaestus) { + hephaestus.permission = { + ...hephaestus.permission, call_omo_agent: "deny", task: "allow", question: questionPermission, ...denyTodoTools, }; } - if (params.agentResult["prometheus"]) { - const agent = params.agentResult["prometheus"] as AgentWithPermission; - agent.permission = { - ...agent.permission, + const prometheus = agentByKey(params.agentResult, "prometheus"); + if (prometheus) { + prometheus.permission = { + ...prometheus.permission, call_omo_agent: "deny", task: "allow", question: questionPermission, @@ -80,10 +85,10 @@ export function applyToolConfig(params: { ...denyTodoTools, }; } - if (params.agentResult["sisyphus-junior"]) { - const agent = params.agentResult["sisyphus-junior"] as AgentWithPermission; - agent.permission = { - ...agent.permission, + const junior = agentByKey(params.agentResult, "sisyphus-junior"); + if (junior) { + junior.permission = { + ...junior.permission, task: "allow", "task_*": "allow", teammate: "allow", diff --git a/src/plugin/chat-message.test.ts b/src/plugin/chat-message.test.ts new file mode 100644 index 00000000..4b5108ad --- /dev/null +++ b/src/plugin/chat-message.test.ts @@ -0,0 +1,118 @@ +import { describe, test, expect } from "bun:test" + +import { createChatMessageHandler } from "./chat-message" + +type ChatMessagePart = { type: string; text?: string; [key: string]: unknown } +type ChatMessageHandlerOutput = { message: Record; parts: ChatMessagePart[] } + +function createMockHandlerArgs(overrides?: { + pluginConfig?: Record + shouldOverride?: boolean +}) { + const appliedSessions: string[] = [] + return { + ctx: { client: { tui: { showToast: async () => {} } } } as any, + pluginConfig: (overrides?.pluginConfig ?? {}) as any, + firstMessageVariantGate: { + shouldOverride: () => overrides?.shouldOverride ?? false, + markApplied: (sessionID: string) => { appliedSessions.push(sessionID) }, + }, + hooks: { + stopContinuationGuard: null, + keywordDetector: null, + claudeCodeHooks: null, + autoSlashCommand: null, + startWork: null, + ralphLoop: null, + } as any, + _appliedSessions: appliedSessions, + } +} + +function createMockInput(agent?: string, model?: { providerID: string; modelID: string }) { + return { + sessionID: "test-session", + agent, + model, + } +} + +function createMockOutput(variant?: string): ChatMessageHandlerOutput { + const message: Record = {} + if (variant !== undefined) { + message["variant"] = variant + } + return { message, parts: [] } +} + +describe("createChatMessageHandler - first message variant", () => { + test("first message: sets variant from fallback chain when user has no selection", async () => { + //#given - first message, no user-selected variant, hephaestus with medium in chain + const args = createMockHandlerArgs({ shouldOverride: true }) + const handler = createChatMessageHandler(args) + const input = createMockInput("hephaestus", { providerID: "openai", modelID: "gpt-5.3-codex" }) + const output = createMockOutput() // no variant set + + //#when + await handler(input, output) + + //#then - should set variant from fallback chain + expect(output.message["variant"]).toBeDefined() + }) + + test("first message: preserves user-selected variant when already set", async () => { + //#given - first message, user already selected "xhigh" variant in OpenCode UI + const args = createMockHandlerArgs({ shouldOverride: true }) + const handler = createChatMessageHandler(args) + const input = createMockInput("hephaestus", { providerID: "openai", modelID: "gpt-5.3-codex" }) + const output = createMockOutput("xhigh") // user selected xhigh + + //#when + await handler(input, output) + + //#then - user's xhigh must be preserved, not overwritten to "medium" + expect(output.message["variant"]).toBe("xhigh") + }) + + test("first message: preserves user-selected 'high' variant", async () => { + //#given - user selected "high" variant + const args = createMockHandlerArgs({ shouldOverride: true }) + const handler = createChatMessageHandler(args) + const input = createMockInput("hephaestus", { providerID: "openai", modelID: "gpt-5.3-codex" }) + const output = createMockOutput("high") + + //#when + await handler(input, output) + + //#then + expect(output.message["variant"]).toBe("high") + }) + + test("subsequent message: does not override existing variant", async () => { + //#given - not first message, variant already set + const args = createMockHandlerArgs({ shouldOverride: false }) + const handler = createChatMessageHandler(args) + const input = createMockInput("hephaestus", { providerID: "openai", modelID: "gpt-5.3-codex" }) + const output = createMockOutput("xhigh") + + //#when + await handler(input, output) + + //#then + expect(output.message["variant"]).toBe("xhigh") + }) + + test("first message: marks gate as applied regardless of variant presence", async () => { + //#given - first message with user-selected variant + const args = createMockHandlerArgs({ shouldOverride: true }) + const handler = createChatMessageHandler(args) + const input = createMockInput("hephaestus", { providerID: "openai", modelID: "gpt-5.3-codex" }) + const output = createMockOutput("xhigh") + + //#when + await handler(input, output) + + //#then - gate should still be marked as applied + expect(args._appliedSessions).toContain("test-session") + }) +}) diff --git a/src/plugin/chat-message.ts b/src/plugin/chat-message.ts index 8cc1b394..e6720320 100644 --- a/src/plugin/chat-message.ts +++ b/src/plugin/chat-message.ts @@ -56,12 +56,14 @@ export function createChatMessageHandler(args: { const message = output.message if (firstMessageVariantGate.shouldOverride(input.sessionID)) { - const variant = - input.model && input.agent - ? resolveVariantForModel(pluginConfig, input.agent, input.model) - : resolveAgentVariant(pluginConfig, input.agent) - if (variant !== undefined) { - message["variant"] = variant + if (message["variant"] === undefined) { + const variant = + input.model && input.agent + ? resolveVariantForModel(pluginConfig, input.agent, input.model) + : resolveAgentVariant(pluginConfig, input.agent) + if (variant !== undefined) { + message["variant"] = variant + } } firstMessageVariantGate.markApplied(input.sessionID) } else { diff --git a/src/plugin/hooks/create-session-hooks.ts b/src/plugin/hooks/create-session-hooks.ts index 82a4379f..d93ec585 100644 --- a/src/plugin/hooks/create-session-hooks.ts +++ b/src/plugin/hooks/create-session-hooks.ts @@ -122,7 +122,7 @@ export function createSessionHooks(args: { ? safeHook("ralph-loop", () => createRalphLoopHook(ctx, { config: pluginConfig.ralph_loop, - checkSessionExists: async (sessionId) => sessionExists(sessionId), + checkSessionExists: async (sessionId) => await sessionExists(sessionId), })) : null diff --git a/src/plugin/session-agent-resolver.ts b/src/plugin/session-agent-resolver.ts index 8d9837ff..6cc12b8c 100644 --- a/src/plugin/session-agent-resolver.ts +++ b/src/plugin/session-agent-resolver.ts @@ -1,4 +1,5 @@ import { log } from "../shared" +import { normalizeSDKResponse } from "../shared" interface SessionMessage { info?: { @@ -19,7 +20,7 @@ export async function resolveSessionAgent( ): Promise { try { const messagesResp = await client.session.messages({ path: { id: sessionId } }) - const messages = (messagesResp.data ?? []) as SessionMessage[] + const messages = normalizeSDKResponse(messagesResp, [] as SessionMessage[]) for (const msg of messages) { if (msg.info?.agent) { diff --git a/src/shared/AGENTS.md b/src/shared/AGENTS.md index db4e1253..b164fa0e 100644 --- a/src/shared/AGENTS.md +++ b/src/shared/AGENTS.md @@ -2,21 +2,21 @@ ## OVERVIEW -84 cross-cutting utilities across 6 subdirectories. Import via barrel: `import { log, deepMerge } from "../../shared"` +96 cross-cutting utilities across 4 subdirectories. Import via barrel: `import { log, deepMerge } from "../../shared"` ## STRUCTURE ``` shared/ ├── logger.ts # File logging (/tmp/oh-my-opencode.log) — 62 imports -├── dynamic-truncator.ts # Token-aware context window management (201 lines) -├── model-resolver.ts # 3-step resolution (Override → Fallback → Default) -├── model-availability.ts # Provider model fetching & fuzzy matching (358 lines) -├── model-requirements.ts # Agent/category fallback chains (160 lines) -├── model-resolution-pipeline.ts # Pipeline orchestration (175 lines) +├── dynamic-truncator.ts # Token-aware context window management (202 lines) +├── model-resolver.ts # 3-step resolution entry point (65 lines) +├── model-availability.ts # Provider model fetching & fuzzy matching (359 lines) +├── model-requirements.ts # Agent/category fallback chains (161 lines) — 11 imports +├── model-resolution-pipeline.ts # Pipeline orchestration (176 lines) ├── model-resolution-types.ts # Resolution request/provenance types ├── model-sanitizer.ts # Model name sanitization ├── model-name-matcher.ts # Model name matching (91 lines) -├── model-suggestion-retry.ts # Suggest models on failure (129 lines) +├── model-suggestion-retry.ts # Suggest models on failure (144 lines) ├── model-cache-availability.ts # Cache availability checking ├── fallback-model-availability.ts # Fallback model logic (67 lines) ├── available-models-fetcher.ts # Fetch models from providers (114 lines) @@ -27,42 +27,34 @@ shared/ ├── session-utils.ts # Session cursor, orchestrator detection ├── session-cursor.ts # Message cursor tracking (85 lines) ├── session-injected-paths.ts # Injected file path tracking -├── permission-compat.ts # Tool restriction enforcement (86 lines) +├── permission-compat.ts # Tool restriction enforcement (87 lines) — 9 imports ├── agent-tool-restrictions.ts # Tool restriction definitions ├── agent-variant.ts # Agent variant from config (91 lines) ├── agent-display-names.ts # Agent display name mapping ├── first-message-variant.ts # First message variant types ├── opencode-config-dir.ts # ~/.config/opencode resolution (138 lines) ├── claude-config-dir.ts # ~/.claude resolution -├── data-path.ts # XDG-compliant storage (47 lines) +├── data-path.ts # XDG-compliant storage (47 lines) — 11 imports ├── jsonc-parser.ts # JSONC with comment support (66 lines) ├── frontmatter.ts # YAML frontmatter extraction (31 lines) — 10 imports ├── deep-merge.ts # Recursive merge (proto-pollution safe, MAX_DEPTH=50) ├── shell-env.ts # Cross-platform shell environment (111 lines) -├── opencode-version.ts # Semantic version comparison (74 lines) +├── opencode-version.ts # Semantic version comparison (80 lines) ├── external-plugin-detector.ts # Plugin conflict detection (137 lines) -├── opencode-server-auth.ts # Authentication utilities (69 lines) +├── opencode-server-auth.ts # Authentication utilities (190 lines) ├── safe-create-hook.ts # Hook error wrapper (24 lines) ├── pattern-matcher.ts # Pattern matching (40 lines) -├── file-utils.ts # File operations (40 lines) — 9 imports +├── file-utils.ts # File operations (34 lines) — 9 imports ├── file-reference-resolver.ts # File reference resolution (85 lines) ├── snake-case.ts # Case conversion (44 lines) ├── tool-name.ts # Tool naming conventions -├── truncate-description.ts # Description truncation ├── port-utils.ts # Port management (48 lines) ├── zip-extractor.ts # ZIP extraction (83 lines) ├── binary-downloader.ts # Binary download (60 lines) -├── skill-path-resolver.ts # Skill path resolution -├── hook-disabled.ts # Hook disable checking -├── config-errors.ts # Config error types -├── disabled-tools.ts # Disabled tools tracking -├── record-type-guard.ts # Record type guard -├── open-code-client-accessors.ts # Client accessor utilities -├── open-code-client-shapes.ts # Client shape types ├── command-executor/ # Shell execution (6 files, 213 lines) ├── git-worktree/ # Git status/diff parsing (8 files, 311 lines) ├── migration/ # Legacy config migration (5 files, 341 lines) -│ ├── config-migration.ts # Migration orchestration (126 lines) +│ ├── config-migration.ts # Migration orchestration (133 lines) │ ├── agent-names.ts # Agent name mapping (70 lines) │ ├── hook-names.ts # Hook name mapping (36 lines) │ └── model-versions.ts # Model version migration (49 lines) @@ -86,9 +78,9 @@ shared/ ## KEY PATTERNS **3-Step Model Resolution** (Override → Fallback → Default): -```typescript -resolveModelWithFallback({ userModel, fallbackChain, availableModels }) -``` +1. **Override**: UI-selected or user-configured model +2. **Fallback**: Provider/model chain with availability checking +3. **Default**: System fallback when no matches found **System Directive Filtering**: ```typescript diff --git a/src/shared/agent-config-integration.test.ts b/src/shared/agent-config-integration.test.ts index e663da9f..1760ca02 100644 --- a/src/shared/agent-config-integration.test.ts +++ b/src/shared/agent-config-integration.test.ts @@ -93,10 +93,10 @@ describe("Agent Config Integration", () => { // then - display names are correct expect(displayNames).toContain("Sisyphus (Ultraworker)") - expect(displayNames).toContain("Atlas (Plan Execution Orchestrator)") + expect(displayNames).toContain("Atlas (Plan Executor)") expect(displayNames).toContain("Prometheus (Plan Builder)") expect(displayNames).toContain("Metis (Plan Consultant)") - expect(displayNames).toContain("Momus (Plan Reviewer)") + expect(displayNames).toContain("Momus (Plan Critic)") expect(displayNames).toContain("oracle") expect(displayNames).toContain("librarian") expect(displayNames).toContain("explore") @@ -112,9 +112,9 @@ describe("Agent Config Integration", () => { // then - correct display names are returned expect(displayNames[0]).toBe("Sisyphus (Ultraworker)") - expect(displayNames[1]).toBe("Atlas (Plan Execution Orchestrator)") + expect(displayNames[1]).toBe("Atlas (Plan Executor)") expect(displayNames[2]).toBe("Sisyphus (Ultraworker)") - expect(displayNames[3]).toBe("Atlas (Plan Execution Orchestrator)") + expect(displayNames[3]).toBe("Atlas (Plan Executor)") expect(displayNames[4]).toBe("Prometheus (Plan Builder)") expect(displayNames[5]).toBe("Prometheus (Plan Builder)") }) @@ -218,7 +218,7 @@ describe("Agent Config Integration", () => { // then - display names are correct expect(sisyphusDisplay).toBe("Sisyphus (Ultraworker)") - expect(atlasDisplay).toBe("Atlas (Plan Execution Orchestrator)") + expect(atlasDisplay).toBe("Atlas (Plan Executor)") }) }) }) diff --git a/src/shared/agent-display-names.test.ts b/src/shared/agent-display-names.test.ts index 628de8b8..3d7276a1 100644 --- a/src/shared/agent-display-names.test.ts +++ b/src/shared/agent-display-names.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "bun:test" -import { AGENT_DISPLAY_NAMES, getAgentDisplayName } from "./agent-display-names" +import { AGENT_DISPLAY_NAMES, getAgentDisplayName, getAgentConfigKey } from "./agent-display-names" describe("getAgentDisplayName", () => { it("returns display name for lowercase config key (new format)", () => { @@ -42,8 +42,8 @@ describe("getAgentDisplayName", () => { // when getAgentDisplayName called const result = getAgentDisplayName(configKey) - // then returns "Atlas (Plan Execution Orchestrator)" - expect(result).toBe("Atlas (Plan Execution Orchestrator)") + // then returns "Atlas (Plan Executor)" + expect(result).toBe("Atlas (Plan Executor)") }) it("returns display name for prometheus", () => { @@ -86,8 +86,8 @@ describe("getAgentDisplayName", () => { // when getAgentDisplayName called const result = getAgentDisplayName(configKey) - // then returns "Momus (Plan Reviewer)" - expect(result).toBe("Momus (Plan Reviewer)") + // then returns "Momus (Plan Critic)" + expect(result).toBe("Momus (Plan Critic)") }) it("returns display name for oracle", () => { @@ -135,16 +135,58 @@ describe("getAgentDisplayName", () => { }) }) +describe("getAgentConfigKey", () => { + it("resolves display name to config key", () => { + // given display name "Sisyphus (Ultraworker)" + // when getAgentConfigKey called + // then returns "sisyphus" + expect(getAgentConfigKey("Sisyphus (Ultraworker)")).toBe("sisyphus") + }) + + it("resolves display name case-insensitively", () => { + // given display name in different case + // when getAgentConfigKey called + // then returns "atlas" + expect(getAgentConfigKey("atlas (plan executor)")).toBe("atlas") + }) + + it("passes through lowercase config keys unchanged", () => { + // given lowercase config key "prometheus" + // when getAgentConfigKey called + // then returns "prometheus" + expect(getAgentConfigKey("prometheus")).toBe("prometheus") + }) + + it("returns lowercased unknown agents", () => { + // given unknown agent name + // when getAgentConfigKey called + // then returns lowercased + expect(getAgentConfigKey("Custom-Agent")).toBe("custom-agent") + }) + + it("resolves all core agent display names", () => { + // given all core display names + // when/then each resolves to its config key + expect(getAgentConfigKey("Hephaestus (Deep Agent)")).toBe("hephaestus") + expect(getAgentConfigKey("Prometheus (Plan Builder)")).toBe("prometheus") + expect(getAgentConfigKey("Atlas (Plan Executor)")).toBe("atlas") + expect(getAgentConfigKey("Metis (Plan Consultant)")).toBe("metis") + expect(getAgentConfigKey("Momus (Plan Critic)")).toBe("momus") + expect(getAgentConfigKey("Sisyphus-Junior")).toBe("sisyphus-junior") + }) +}) + describe("AGENT_DISPLAY_NAMES", () => { it("contains all expected agent mappings", () => { // given expected mappings const expectedMappings = { sisyphus: "Sisyphus (Ultraworker)", - atlas: "Atlas (Plan Execution Orchestrator)", + hephaestus: "Hephaestus (Deep Agent)", prometheus: "Prometheus (Plan Builder)", + atlas: "Atlas (Plan Executor)", "sisyphus-junior": "Sisyphus-Junior", metis: "Metis (Plan Consultant)", - momus: "Momus (Plan Reviewer)", + momus: "Momus (Plan Critic)", oracle: "oracle", librarian: "librarian", explore: "explore", diff --git a/src/shared/agent-display-names.ts b/src/shared/agent-display-names.ts index 82c08b2c..a0bda224 100644 --- a/src/shared/agent-display-names.ts +++ b/src/shared/agent-display-names.ts @@ -5,11 +5,12 @@ */ export const AGENT_DISPLAY_NAMES: Record = { sisyphus: "Sisyphus (Ultraworker)", - atlas: "Atlas (Plan Execution Orchestrator)", + hephaestus: "Hephaestus (Deep Agent)", prometheus: "Prometheus (Plan Builder)", + atlas: "Atlas (Plan Executor)", "sisyphus-junior": "Sisyphus-Junior", metis: "Metis (Plan Consultant)", - momus: "Momus (Plan Reviewer)", + momus: "Momus (Plan Critic)", oracle: "oracle", librarian: "librarian", explore: "explore", @@ -34,4 +35,20 @@ export function getAgentDisplayName(configKey: string): string { // Unknown agent: return original key return configKey +} + +const REVERSE_DISPLAY_NAMES: Record = Object.fromEntries( + Object.entries(AGENT_DISPLAY_NAMES).map(([key, displayName]) => [displayName.toLowerCase(), key]), +) + +/** + * Resolve an agent name (display name or config key) to its lowercase config key. + * "Atlas (Plan Executor)" → "atlas", "atlas" → "atlas", "unknown" → "unknown" + */ +export function getAgentConfigKey(agentName: string): string { + const lower = agentName.toLowerCase() + const reversed = REVERSE_DISPLAY_NAMES[lower] + if (reversed !== undefined) return reversed + if (AGENT_DISPLAY_NAMES[lower] !== undefined) return lower + return lower } \ No newline at end of file diff --git a/src/shared/available-models-fetcher.ts b/src/shared/available-models-fetcher.ts index b19defce..790ad77e 100644 --- a/src/shared/available-models-fetcher.ts +++ b/src/shared/available-models-fetcher.ts @@ -2,6 +2,7 @@ import { addModelsFromModelsJsonCache } from "./models-json-cache-reader" import { getModelListFunction, getProviderListFunction } from "./open-code-client-accessors" import { addModelsFromProviderModelsCache } from "./provider-models-cache-model-reader" import { log } from "./logger" +import { normalizeSDKResponse } from "./normalize-sdk-response" export async function getConnectedProviders(client: unknown): Promise { const providerList = getProviderListFunction(client) @@ -53,7 +54,7 @@ export async function fetchAvailableModels( const modelSet = new Set() try { const modelsResult = await modelList() - const models = modelsResult.data ?? [] + const models = normalizeSDKResponse(modelsResult, [] as Array<{ provider?: string; id?: string }>) for (const model of models) { if (model.provider && model.id) { modelSet.add(`${model.provider}/${model.id}`) @@ -92,7 +93,7 @@ export async function fetchAvailableModels( if (modelList) { try { const modelsResult = await modelList() - const models = modelsResult.data ?? [] + const models = normalizeSDKResponse(modelsResult, [] as Array<{ provider?: string; id?: string }>) for (const model of models) { if (!model.provider || !model.id) continue diff --git a/src/shared/dynamic-truncator.ts b/src/shared/dynamic-truncator.ts index 017bca16..dbd90466 100644 --- a/src/shared/dynamic-truncator.ts +++ b/src/shared/dynamic-truncator.ts @@ -1,4 +1,5 @@ import type { PluginInput } from "@opencode-ai/plugin"; +import { normalizeSDKResponse } from "./normalize-sdk-response" const ANTHROPIC_ACTUAL_LIMIT = process.env.ANTHROPIC_1M_CONTEXT === "true" || @@ -119,7 +120,7 @@ export async function getContextWindowUsage( path: { id: sessionID }, }); - const messages = (response.data ?? response) as MessageWrapper[]; + const messages = normalizeSDKResponse(response, [] as MessageWrapper[], { preferResponseOnMissingData: true }) const assistantMessages = messages .filter((m) => m.info.role === "assistant") diff --git a/src/shared/index.ts b/src/shared/index.ts index 07d8bd86..85a62b83 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -22,6 +22,7 @@ export type { OpenCodeConfigPaths, } from "./opencode-config-dir-types" export * from "./opencode-version" +export * from "./opencode-storage-detection" export * from "./permission-compat" export * from "./external-plugin-detector" export * from "./zip-extractor" @@ -37,7 +38,7 @@ export { resolveModelPipeline } from "./model-resolution-pipeline" export type { ModelResolutionRequest, ModelResolutionProvenance, - ModelResolutionResult as ModelResolutionPipelineResult, + ModelResolutionResult, } from "./model-resolution-types" export * from "./model-availability" export * from "./connected-providers-cache" @@ -45,7 +46,11 @@ export * from "./session-utils" export * from "./tmux" export * from "./model-suggestion-retry" export * from "./opencode-server-auth" +export * from "./opencode-http-api" export * from "./port-utils" export * from "./git-worktree" export * from "./safe-create-hook" export * from "./truncate-description" +export * from "./opencode-storage-paths" +export * from "./opencode-message-dir" +export * from "./normalize-sdk-response" diff --git a/src/shared/model-availability.ts b/src/shared/model-availability.ts index 1ff696ee..0943ce85 100644 --- a/src/shared/model-availability.ts +++ b/src/shared/model-availability.ts @@ -3,6 +3,7 @@ import { join } from "path" import { log } from "./logger" import { getOpenCodeCacheDir } from "./data-path" import * as connectedProvidersCache from "./connected-providers-cache" +import { normalizeSDKResponse } from "./normalize-sdk-response" /** * Fuzzy match a target model name against available models @@ -159,7 +160,7 @@ export async function fetchAvailableModels( const modelSet = new Set() try { const modelsResult = await client.model.list() - const models = modelsResult.data ?? [] + const models = normalizeSDKResponse(modelsResult, [] as Array<{ provider?: string; id?: string }>) for (const model of models) { if (model?.provider && model?.id) { modelSet.add(`${model.provider}/${model.id}`) @@ -261,7 +262,7 @@ export async function fetchAvailableModels( if (client?.model?.list) { try { const modelsResult = await client.model.list() - const models = modelsResult.data ?? [] + const models = normalizeSDKResponse(modelsResult, [] as Array<{ provider?: string; id?: string }>) for (const model of models) { if (!model?.provider || !model?.id) continue diff --git a/src/shared/normalize-sdk-response.test.ts b/src/shared/normalize-sdk-response.test.ts new file mode 100644 index 00000000..870519d7 --- /dev/null +++ b/src/shared/normalize-sdk-response.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "bun:test" +import { normalizeSDKResponse } from "./normalize-sdk-response" + +describe("normalizeSDKResponse", () => { + it("returns data array when response includes data", () => { + //#given + const response = { data: [{ id: "1" }] } + + //#when + const result = normalizeSDKResponse(response, [] as Array<{ id: string }>) + + //#then + expect(result).toEqual([{ id: "1" }]) + }) + + it("returns fallback array when data is missing", () => { + //#given + const response = {} + const fallback = [{ id: "fallback" }] + + //#when + const result = normalizeSDKResponse(response, fallback) + + //#then + expect(result).toEqual(fallback) + }) + + it("returns response array directly when SDK returns plain array", () => { + //#given + const response = [{ id: "2" }] + + //#when + const result = normalizeSDKResponse(response, [] as Array<{ id: string }>) + + //#then + expect(result).toEqual([{ id: "2" }]) + }) + + it("returns response when data missing and preferResponseOnMissingData is true", () => { + //#given + const response = { value: "legacy" } + + //#when + const result = normalizeSDKResponse(response, { value: "fallback" }, { preferResponseOnMissingData: true }) + + //#then + expect(result).toEqual({ value: "legacy" }) + }) + + it("returns fallback for null response", () => { + //#given + const response = null + + //#when + const result = normalizeSDKResponse(response, [] as string[]) + + //#then + expect(result).toEqual([]) + }) + + it("returns object fallback for direct data nullish pattern", () => { + //#given + const response = { data: undefined as { connected: string[] } | undefined } + const fallback = { connected: [] } + + //#when + const result = normalizeSDKResponse(response, fallback) + + //#then + expect(result).toEqual(fallback) + }) +}) diff --git a/src/shared/normalize-sdk-response.ts b/src/shared/normalize-sdk-response.ts new file mode 100644 index 00000000..080cc992 --- /dev/null +++ b/src/shared/normalize-sdk-response.ts @@ -0,0 +1,36 @@ +export interface NormalizeSDKResponseOptions { + preferResponseOnMissingData?: boolean +} + +export function normalizeSDKResponse( + response: unknown, + fallback: TData, + options?: NormalizeSDKResponseOptions, +): TData { + if (response === null || response === undefined) { + return fallback + } + + if (Array.isArray(response)) { + return response as TData + } + + if (typeof response === "object" && "data" in response) { + const data = (response as { data?: unknown }).data + if (data !== null && data !== undefined) { + return data as TData + } + + if (options?.preferResponseOnMissingData === true) { + return response as TData + } + + return fallback + } + + if (options?.preferResponseOnMissingData === true) { + return response as TData + } + + return fallback +} diff --git a/src/shared/opencode-http-api.test.ts b/src/shared/opencode-http-api.test.ts new file mode 100644 index 00000000..80b86bae --- /dev/null +++ b/src/shared/opencode-http-api.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, vi, beforeEach } from "bun:test" +import { getServerBaseUrl, patchPart, deletePart } from "./opencode-http-api" + +// Mock fetch globally +const mockFetch = vi.fn() +global.fetch = mockFetch + +// Mock log +vi.mock("./logger", () => ({ + log: vi.fn(), +})) + +import { log } from "./logger" + +describe("getServerBaseUrl", () => { + it("returns baseUrl from client._client.getConfig().baseUrl", () => { + // given + const mockClient = { + _client: { + getConfig: () => ({ baseUrl: "https://api.example.com" }), + }, + } + + // when + const result = getServerBaseUrl(mockClient) + + // then + expect(result).toBe("https://api.example.com") + }) + + it("returns baseUrl from client.session._client.getConfig().baseUrl when first attempt fails", () => { + // given + const mockClient = { + _client: { + getConfig: () => ({}), + }, + session: { + _client: { + getConfig: () => ({ baseUrl: "https://session.example.com" }), + }, + }, + } + + // when + const result = getServerBaseUrl(mockClient) + + // then + expect(result).toBe("https://session.example.com") + }) + + it("returns null for incompatible client", () => { + // given + const mockClient = {} + + // when + const result = getServerBaseUrl(mockClient) + + // then + expect(result).toBeNull() + }) +}) + +describe("patchPart", () => { + beforeEach(() => { + vi.clearAllMocks() + mockFetch.mockResolvedValue({ ok: true }) + process.env.OPENCODE_SERVER_PASSWORD = "testpassword" + process.env.OPENCODE_SERVER_USERNAME = "opencode" + }) + + it("constructs correct URL and sends PATCH with auth", async () => { + // given + const mockClient = { + _client: { + getConfig: () => ({ baseUrl: "https://api.example.com" }), + }, + } + const sessionID = "ses123" + const messageID = "msg456" + const partID = "part789" + const body = { content: "test" } + + // when + const result = await patchPart(mockClient, sessionID, messageID, partID, body) + + // then + expect(result).toBe(true) + expect(mockFetch).toHaveBeenCalledWith( + "https://api.example.com/session/ses123/message/msg456/part/part789", + expect.objectContaining({ + method: "PATCH", + headers: { + "Content-Type": "application/json", + "Authorization": "Basic b3BlbmNvZGU6dGVzdHBhc3N3b3Jk", + }, + body: JSON.stringify(body), + signal: expect.any(AbortSignal), + }) + ) + }) + + it("returns false on network error", async () => { + // given + const mockClient = { + _client: { + getConfig: () => ({ baseUrl: "https://api.example.com" }), + }, + } + mockFetch.mockRejectedValue(new Error("Network error")) + + // when + const result = await patchPart(mockClient, "ses123", "msg456", "part789", {}) + + // then + expect(result).toBe(false) + expect(log).toHaveBeenCalledWith("[opencode-http-api] PATCH error", { + message: "Network error", + url: "https://api.example.com/session/ses123/message/msg456/part/part789", + }) + }) +}) + +describe("deletePart", () => { + beforeEach(() => { + vi.clearAllMocks() + mockFetch.mockResolvedValue({ ok: true }) + process.env.OPENCODE_SERVER_PASSWORD = "testpassword" + process.env.OPENCODE_SERVER_USERNAME = "opencode" + }) + + it("constructs correct URL and sends DELETE", async () => { + // given + const mockClient = { + _client: { + getConfig: () => ({ baseUrl: "https://api.example.com" }), + }, + } + const sessionID = "ses123" + const messageID = "msg456" + const partID = "part789" + + // when + const result = await deletePart(mockClient, sessionID, messageID, partID) + + // then + expect(result).toBe(true) + expect(mockFetch).toHaveBeenCalledWith( + "https://api.example.com/session/ses123/message/msg456/part/part789", + expect.objectContaining({ + method: "DELETE", + headers: { + "Authorization": "Basic b3BlbmNvZGU6dGVzdHBhc3N3b3Jk", + }, + signal: expect.any(AbortSignal), + }) + ) + }) + + it("returns false on non-ok response", async () => { + // given + const mockClient = { + _client: { + getConfig: () => ({ baseUrl: "https://api.example.com" }), + }, + } + mockFetch.mockResolvedValue({ ok: false, status: 404 }) + + // when + const result = await deletePart(mockClient, "ses123", "msg456", "part789") + + // then + expect(result).toBe(false) + expect(log).toHaveBeenCalledWith("[opencode-http-api] DELETE failed", { + status: 404, + url: "https://api.example.com/session/ses123/message/msg456/part/part789", + }) + }) +}) \ No newline at end of file diff --git a/src/shared/opencode-http-api.ts b/src/shared/opencode-http-api.ts new file mode 100644 index 00000000..451d98e6 --- /dev/null +++ b/src/shared/opencode-http-api.ts @@ -0,0 +1,140 @@ +import { getServerBasicAuthHeader } from "./opencode-server-auth" +import { log } from "./logger" +import { isRecord } from "./record-type-guard" + +type UnknownRecord = Record + +function getInternalClient(client: unknown): UnknownRecord | null { + if (!isRecord(client)) { + return null + } + + const internal = client["_client"] + return isRecord(internal) ? internal : null +} + +export function getServerBaseUrl(client: unknown): string | null { + // Try client._client.getConfig().baseUrl + const internal = getInternalClient(client) + if (internal) { + const getConfig = internal["getConfig"] + if (typeof getConfig === "function") { + const config = getConfig() + if (isRecord(config)) { + const baseUrl = config["baseUrl"] + if (typeof baseUrl === "string") { + return baseUrl + } + } + } + } + + // Try client.session._client.getConfig().baseUrl + if (isRecord(client)) { + const session = client["session"] + if (isRecord(session)) { + const internal = session["_client"] + if (isRecord(internal)) { + const getConfig = internal["getConfig"] + if (typeof getConfig === "function") { + const config = getConfig() + if (isRecord(config)) { + const baseUrl = config["baseUrl"] + if (typeof baseUrl === "string") { + return baseUrl + } + } + } + } + } + } + + return null +} + +export async function patchPart( + client: unknown, + sessionID: string, + messageID: string, + partID: string, + body: Record +): Promise { + const baseUrl = getServerBaseUrl(client) + if (!baseUrl) { + log("[opencode-http-api] Could not extract baseUrl from client") + return false + } + + const auth = getServerBasicAuthHeader() + if (!auth) { + log("[opencode-http-api] No auth header available") + return false + } + + const url = `${baseUrl}/session/${encodeURIComponent(sessionID)}/message/${encodeURIComponent(messageID)}/part/${encodeURIComponent(partID)}` + + try { + const response = await fetch(url, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "Authorization": auth, + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(10_000), + }) + + if (!response.ok) { + log("[opencode-http-api] PATCH failed", { status: response.status, url }) + return false + } + + return true + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + log("[opencode-http-api] PATCH error", { message, url }) + return false + } +} + +export async function deletePart( + client: unknown, + sessionID: string, + messageID: string, + partID: string +): Promise { + const baseUrl = getServerBaseUrl(client) + if (!baseUrl) { + log("[opencode-http-api] Could not extract baseUrl from client") + return false + } + + const auth = getServerBasicAuthHeader() + if (!auth) { + log("[opencode-http-api] No auth header available") + return false + } + + const url = `${baseUrl}/session/${encodeURIComponent(sessionID)}/message/${encodeURIComponent(messageID)}/part/${encodeURIComponent(partID)}` + + try { + const response = await fetch(url, { + method: "DELETE", + headers: { + "Authorization": auth, + }, + signal: AbortSignal.timeout(10_000), + }) + + if (!response.ok) { + log("[opencode-http-api] DELETE failed", { status: response.status, url }) + return false + } + + return true + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + log("[opencode-http-api] DELETE error", { message, url }) + return false + } +} \ No newline at end of file diff --git a/src/shared/opencode-message-dir.test.ts b/src/shared/opencode-message-dir.test.ts new file mode 100644 index 00000000..521ddcdc --- /dev/null +++ b/src/shared/opencode-message-dir.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, beforeEach, afterEach, afterAll, mock } from "bun:test" +import { mkdirSync, rmSync } from "node:fs" +import { join } from "node:path" +import { tmpdir } from "node:os" +import { randomUUID } from "node:crypto" + +const TEST_STORAGE = join(tmpdir(), `omo-msgdir-test-${randomUUID()}`) +const TEST_MESSAGE_STORAGE = join(TEST_STORAGE, "message") + +mock.module("./opencode-storage-paths", () => ({ + OPENCODE_STORAGE: TEST_STORAGE, + MESSAGE_STORAGE: TEST_MESSAGE_STORAGE, + PART_STORAGE: join(TEST_STORAGE, "part"), + SESSION_STORAGE: join(TEST_STORAGE, "session"), +})) + +mock.module("./opencode-storage-detection", () => ({ + isSqliteBackend: () => false, + resetSqliteBackendCache: () => {}, +})) + +const { getMessageDir } = await import("./opencode-message-dir") + +describe("getMessageDir", () => { + beforeEach(() => { + mkdirSync(TEST_MESSAGE_STORAGE, { recursive: true }) + }) + + afterEach(() => { + try { rmSync(TEST_MESSAGE_STORAGE, { recursive: true, force: true }) } catch {} + }) + + afterAll(() => { + try { rmSync(TEST_STORAGE, { recursive: true, force: true }) } catch {} + }) + + it("returns null when sessionID does not start with ses_", () => { + //#given - sessionID without ses_ prefix + //#when + const result = getMessageDir("invalid") + //#then + expect(result).toBe(null) + }) + + it("returns null when MESSAGE_STORAGE does not exist", () => { + //#given + rmSync(TEST_MESSAGE_STORAGE, { recursive: true, force: true }) + //#when + const result = getMessageDir("ses_123") + //#then + expect(result).toBe(null) + }) + + it("returns direct path when session exists directly", () => { + //#given + const sessionDir = join(TEST_MESSAGE_STORAGE, "ses_123") + mkdirSync(sessionDir, { recursive: true }) + //#when + const result = getMessageDir("ses_123") + //#then + expect(result).toBe(sessionDir) + }) + + it("returns subdirectory path when session exists in subdirectory", () => { + //#given + const sessionDir = join(TEST_MESSAGE_STORAGE, "subdir", "ses_123") + mkdirSync(sessionDir, { recursive: true }) + //#when + const result = getMessageDir("ses_123") + //#then + expect(result).toBe(sessionDir) + }) + + it("returns null for path traversal attempts with ..", () => { + //#given - sessionID containing path traversal + //#when + const result = getMessageDir("ses_../etc/passwd") + //#then + expect(result).toBe(null) + }) + + it("returns null for path traversal attempts with forward slash", () => { + //#given - sessionID containing forward slash + //#when + const result = getMessageDir("ses_foo/bar") + //#then + expect(result).toBe(null) + }) + + it("returns null for path traversal attempts with backslash", () => { + //#given - sessionID containing backslash + //#when + const result = getMessageDir("ses_foo\\bar") + //#then + expect(result).toBe(null) + }) + + it("returns null when session not found anywhere", () => { + //#given + mkdirSync(join(TEST_MESSAGE_STORAGE, "subdir1"), { recursive: true }) + mkdirSync(join(TEST_MESSAGE_STORAGE, "subdir2"), { recursive: true }) + //#when + const result = getMessageDir("ses_nonexistent") + //#then + expect(result).toBe(null) + }) +}) \ No newline at end of file diff --git a/src/shared/opencode-message-dir.ts b/src/shared/opencode-message-dir.ts new file mode 100644 index 00000000..c8d8e3b3 --- /dev/null +++ b/src/shared/opencode-message-dir.ts @@ -0,0 +1,31 @@ +import { existsSync, readdirSync } from "node:fs" +import { join } from "node:path" +import { MESSAGE_STORAGE } from "./opencode-storage-paths" +import { isSqliteBackend } from "./opencode-storage-detection" +import { log } from "./logger" + +export function getMessageDir(sessionID: string): string | null { + if (!sessionID.startsWith("ses_")) return null + if (/[/\\]|\.\./.test(sessionID)) return null + if (isSqliteBackend()) return null + if (!existsSync(MESSAGE_STORAGE)) return null + + const directPath = join(MESSAGE_STORAGE, sessionID) + if (existsSync(directPath)) { + return directPath + } + + try { + for (const dir of readdirSync(MESSAGE_STORAGE)) { + const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) + if (existsSync(sessionPath)) { + return sessionPath + } + } + } catch (error) { + log("[opencode-message-dir] Failed to scan message directories", { sessionID, error: String(error) }) + return null + } + + return null +} \ No newline at end of file diff --git a/src/shared/opencode-storage-detection.test.ts b/src/shared/opencode-storage-detection.test.ts new file mode 100644 index 00000000..12238e50 --- /dev/null +++ b/src/shared/opencode-storage-detection.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect, beforeEach, mock } from "bun:test" +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { tmpdir } from "node:os" +import { randomUUID } from "node:crypto" + +const TEST_DATA_DIR = join(tmpdir(), `omo-sqlite-detect-${randomUUID()}`) +const DB_PATH = join(TEST_DATA_DIR, "opencode", "opencode.db") + +let versionCheckCalls: string[] = [] +let versionReturnValue = true +const SQLITE_VERSION = "1.1.53" + +// Inline isSqliteBackend implementation to avoid mock pollution from other test files. +// Other files (e.g., opencode-message-dir.test.ts) mock ./opencode-storage-detection globally, +// making dynamic import unreliable. By inlining, we test the actual logic with controlled deps. +const NOT_CACHED = Symbol("NOT_CACHED") +const FALSE_PENDING_RETRY = Symbol("FALSE_PENDING_RETRY") +let cachedResult: true | false | typeof NOT_CACHED | typeof FALSE_PENDING_RETRY = NOT_CACHED + +function isSqliteBackend(): boolean { + if (cachedResult === true) return true + if (cachedResult === false) return false + if (cachedResult === FALSE_PENDING_RETRY) { + const versionOk = (() => { versionCheckCalls.push(SQLITE_VERSION); return versionReturnValue })() + const dbPath = join(TEST_DATA_DIR, "opencode", "opencode.db") + const dbExists = existsSync(dbPath) + const result = versionOk && dbExists + cachedResult = result + return result + } + const versionOk = (() => { versionCheckCalls.push(SQLITE_VERSION); return versionReturnValue })() + const dbPath = join(TEST_DATA_DIR, "opencode", "opencode.db") + const dbExists = existsSync(dbPath) + const result = versionOk && dbExists + if (result) { cachedResult = true } + else { cachedResult = FALSE_PENDING_RETRY } + return result +} + +function resetSqliteBackendCache(): void { + cachedResult = NOT_CACHED +} + +describe("isSqliteBackend", () => { + beforeEach(() => { + resetSqliteBackendCache() + versionCheckCalls = [] + versionReturnValue = true + try { rmSync(TEST_DATA_DIR, { recursive: true, force: true }) } catch {} + }) + + it("returns false when version is below threshold", () => { + //#given + versionReturnValue = false + mkdirSync(join(TEST_DATA_DIR, "opencode"), { recursive: true }) + writeFileSync(DB_PATH, "") + + //#when + const result = isSqliteBackend() + + //#then + expect(result).toBe(false) + expect(versionCheckCalls).toContain("1.1.53") + }) + + it("returns false when DB file does not exist", () => { + //#given + versionReturnValue = true + + //#when + const result = isSqliteBackend() + + //#then + expect(result).toBe(false) + }) + + it("returns true when version is at or above threshold and DB exists", () => { + //#given + versionReturnValue = true + mkdirSync(join(TEST_DATA_DIR, "opencode"), { recursive: true }) + writeFileSync(DB_PATH, "") + + //#when + const result = isSqliteBackend() + + //#then + expect(result).toBe(true) + expect(versionCheckCalls).toContain("1.1.53") + }) + + it("caches true permanently and does not re-check", () => { + //#given + versionReturnValue = true + mkdirSync(join(TEST_DATA_DIR, "opencode"), { recursive: true }) + writeFileSync(DB_PATH, "") + + //#when + isSqliteBackend() + isSqliteBackend() + isSqliteBackend() + + //#then + expect(versionCheckCalls.length).toBe(1) + }) + + it("retries once when first result is false, then caches permanently", () => { + //#given + versionReturnValue = true + + //#when: first call — DB does not exist + const first = isSqliteBackend() + + //#then + expect(first).toBe(false) + expect(versionCheckCalls.length).toBe(1) + + //#when: second call — DB still does not exist (retry) + const second = isSqliteBackend() + + //#then: retried once + expect(second).toBe(false) + expect(versionCheckCalls.length).toBe(2) + + //#when: third call — no more retries + const third = isSqliteBackend() + + //#then: no further checks + expect(third).toBe(false) + expect(versionCheckCalls.length).toBe(2) + }) + + it("recovers on retry when DB appears after first false", () => { + //#given + versionReturnValue = true + + //#when: first call — DB does not exist + const first = isSqliteBackend() + + //#then + expect(first).toBe(false) + + //#given: DB appears before retry + mkdirSync(join(TEST_DATA_DIR, "opencode"), { recursive: true }) + writeFileSync(DB_PATH, "") + + //#when: second call — retry finds DB + const second = isSqliteBackend() + + //#then: recovers to true and caches permanently + expect(second).toBe(true) + expect(versionCheckCalls.length).toBe(2) + + //#when: third call — cached true + const third = isSqliteBackend() + + //#then: no further checks + expect(third).toBe(true) + expect(versionCheckCalls.length).toBe(2) + }) +}) \ No newline at end of file diff --git a/src/shared/opencode-storage-detection.ts b/src/shared/opencode-storage-detection.ts new file mode 100644 index 00000000..930f9e1f --- /dev/null +++ b/src/shared/opencode-storage-detection.ts @@ -0,0 +1,34 @@ +import { existsSync } from "node:fs" +import { join } from "node:path" +import { getDataDir } from "./data-path" +import { isOpenCodeVersionAtLeast, OPENCODE_SQLITE_VERSION } from "./opencode-version" + +const NOT_CACHED = Symbol("NOT_CACHED") +const FALSE_PENDING_RETRY = Symbol("FALSE_PENDING_RETRY") +let cachedResult: true | false | typeof NOT_CACHED | typeof FALSE_PENDING_RETRY = NOT_CACHED + +export function isSqliteBackend(): boolean { + if (cachedResult === true) return true + if (cachedResult === false) return false + + const check = (): boolean => { + const versionOk = isOpenCodeVersionAtLeast(OPENCODE_SQLITE_VERSION) + const dbPath = join(getDataDir(), "opencode", "opencode.db") + return versionOk && existsSync(dbPath) + } + + if (cachedResult === FALSE_PENDING_RETRY) { + const result = check() + cachedResult = result + return result + } + + const result = check() + if (result) { cachedResult = true } + else { cachedResult = FALSE_PENDING_RETRY } + return result +} + +export function resetSqliteBackendCache(): void { + cachedResult = NOT_CACHED +} \ No newline at end of file diff --git a/src/shared/opencode-storage-paths.ts b/src/shared/opencode-storage-paths.ts new file mode 100644 index 00000000..baf1a4dc --- /dev/null +++ b/src/shared/opencode-storage-paths.ts @@ -0,0 +1,7 @@ +import { join } from "node:path" +import { getOpenCodeStorageDir } from "./data-path" + +export const OPENCODE_STORAGE = getOpenCodeStorageDir() +export const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message") +export const PART_STORAGE = join(OPENCODE_STORAGE, "part") +export const SESSION_STORAGE = join(OPENCODE_STORAGE, "session") \ No newline at end of file diff --git a/src/shared/opencode-version.ts b/src/shared/opencode-version.ts index f02161ac..e4eecd76 100644 --- a/src/shared/opencode-version.ts +++ b/src/shared/opencode-version.ts @@ -15,6 +15,12 @@ export const MINIMUM_OPENCODE_VERSION = "1.1.1" */ export const OPENCODE_NATIVE_AGENTS_INJECTION_VERSION = "1.1.37" +/** + * OpenCode version that introduced SQLite backend for storage. + * When this version is detected AND opencode.db exists, SQLite backend is used. + */ +export const OPENCODE_SQLITE_VERSION = "1.1.53" + const NOT_CACHED = Symbol("NOT_CACHED") let cachedVersion: string | null | typeof NOT_CACHED = NOT_CACHED diff --git a/src/shared/session-utils.ts b/src/shared/session-utils.ts index eb983974..5884da78 100644 --- a/src/shared/session-utils.ts +++ b/src/shared/session-utils.ts @@ -1,27 +1,25 @@ -import * as path from "node:path" -import * as os from "node:os" -import { existsSync, readdirSync } from "node:fs" -import { join } from "node:path" -import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../features/hook-message-injector" +import { findNearestMessageWithFields, findNearestMessageWithFieldsFromSDK } from "../features/hook-message-injector" +import { getMessageDir } from "./opencode-message-dir" +import { isSqliteBackend } from "./opencode-storage-detection" +import { log } from "./logger" +import { getAgentConfigKey } from "./agent-display-names" +import type { PluginInput } from "@opencode-ai/plugin" -export function getMessageDir(sessionID: string): string | null { - if (!existsSync(MESSAGE_STORAGE)) return null +export async function isCallerOrchestrator(sessionID?: string, client?: PluginInput["client"]): Promise { + if (!sessionID) return false - const directPath = join(MESSAGE_STORAGE, sessionID) - if (existsSync(directPath)) return directPath - - for (const dir of readdirSync(MESSAGE_STORAGE)) { - const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) - if (existsSync(sessionPath)) return sessionPath + if (isSqliteBackend() && client) { + try { + const nearest = await findNearestMessageWithFieldsFromSDK(client, sessionID) + return getAgentConfigKey(nearest?.agent ?? "") === "atlas" + } catch (error) { + log("[session-utils] SDK orchestrator check failed", { sessionID, error: String(error) }) + return false + } } - return null -} - -export function isCallerOrchestrator(sessionID?: string): boolean { - if (!sessionID) return false const messageDir = getMessageDir(sessionID) if (!messageDir) return false const nearest = findNearestMessageWithFields(messageDir) - return nearest?.agent?.toLowerCase() === "atlas" + return getAgentConfigKey(nearest?.agent ?? "") === "atlas" } diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index ac6e359b..6c8731ca 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -2,19 +2,19 @@ ## OVERVIEW -24 tools across 14 directories. Two patterns: Direct ToolDefinition (static) and Factory Function (context-dependent). +26 tools across 14 directories. Two patterns: Direct ToolDefinition (static) and Factory Function (context-dependent). ## STRUCTURE ``` tools/ ├── delegate-task/ # Category routing (constants.ts 569 lines, tools.ts 213 lines) -├── task/ # 4 individual tools: create, list, get, update (task-create.ts, task-list.ts, task-get.ts, task-update.ts) +├── task/ # 4 tools: create, list, get, update (task-create.ts, task-list.ts, task-get.ts, task-update.ts) ├── lsp/ # 6 LSP tools: goto_definition, find_references, symbols, diagnostics, prepare_rename, rename ├── ast-grep/ # 2 tools: search, replace (25 languages) -├── grep/ # Custom grep (60s timeout, 10MB limit) -├── glob/ # File search (60s timeout, 100 file limit) -├── session-manager/ # 4 tools: list, read, search, info (151 lines) -├── call-omo-agent/ # Direct agent invocation (57 lines) +├── grep/ # Content search (60s timeout, 10MB limit) +├── glob/ # File pattern matching (60s timeout, 100 file limit) +├── session-manager/ # 4 tools: list, read, search, info +├── call-omo-agent/ # Direct agent invocation (explore/librarian) ├── background-task/ # background_output, background_cancel ├── interactive-bash/ # Tmux session management (135 lines) ├── look-at/ # Multimodal PDF/image analysis (156 lines) @@ -27,13 +27,14 @@ tools/ | Tool | Category | Pattern | Key Logic | |------|----------|---------|-----------| -| `task_create` | Task | Factory | Create task with auto-generated T-{uuid} ID, threadID recording | -| `task_list` | Task | Factory | List active tasks with summary (excludes completed/deleted) | -| `task_get` | Task | Factory | Retrieve full task object by ID | -| `task_update` | Task | Factory | Update task fields, supports addBlocks/addBlockedBy for dependencies | +| `task_create` | Task | Factory | Auto-generated T-{uuid} ID, threadID recording, dependency management | +| `task_list` | Task | Factory | Active tasks with summary (excludes completed/deleted), filters unresolved blockers | +| `task_get` | Task | Factory | Full task object by ID | +| `task_update` | Task | Factory | Status/field updates, additive addBlocks/addBlockedBy for dependencies | +| `task` | Delegation | Factory | Category routing with skill injection, background execution | | `call_omo_agent` | Agent | Factory | Direct explore/librarian invocation | -| `background_output` | Background | Factory | Retrieve background task result | -| `background_cancel` | Background | Factory | Cancel running background tasks | +| `background_output` | Background | Factory | Retrieve background task result (block, timeout, full_session) | +| `background_cancel` | Background | Factory | Cancel running/all background tasks | | `lsp_goto_definition` | LSP | Direct | Jump to symbol definition | | `lsp_find_references` | LSP | Direct | Find all usages across workspace | | `lsp_symbols` | LSP | Direct | Document or workspace symbol search | @@ -41,121 +42,33 @@ tools/ | `lsp_prepare_rename` | LSP | Direct | Validate rename is possible | | `lsp_rename` | LSP | Direct | Rename symbol across workspace | | `ast_grep_search` | Search | Factory | AST-aware code search (25 languages) | -| `ast_grep_replace` | Search | Factory | AST-aware code replacement | +| `ast_grep_replace` | Search | Factory | AST-aware code replacement (dry-run default) | | `grep` | Search | Factory | Regex content search with safety limits | | `glob` | Search | Factory | File pattern matching | | `session_list` | Session | Factory | List all sessions | -| `session_read` | Session | Factory | Read session messages | +| `session_read` | Session | Factory | Read session messages with filters | | `session_search` | Session | Factory | Search across sessions | | `session_info` | Session | Factory | Session metadata and stats | | `interactive_bash` | System | Direct | Tmux session management | -| `look_at` | System | Factory | Multimodal PDF/image analysis | -| `skill` | Skill | Factory | Execute skill with MCP capabilities | -| `skill_mcp` | Skill | Factory | Call MCP tools/resources/prompts | -| `slashcommand` | Command | Factory | Slash command dispatch | - -## TASK TOOLS - -Task management system with auto-generated T-{uuid} IDs, dependency tracking, and OpenCode Todo API sync. - -### task_create - -Create a new task with auto-generated ID and threadID recording. - -**Args:** -| Arg | Type | Required | Description | -|-----|------|----------|-------------| -| `subject` | string | Yes | Task subject/title | -| `description` | string | No | Task description | -| `activeForm` | string | No | Active form (present continuous) | -| `metadata` | Record | No | Task metadata | -| `blockedBy` | string[] | No | Task IDs that must complete before this task | -| `blocks` | string[] | No | Task IDs this task blocks | -| `repoURL` | string | No | Repository URL | -| `parentID` | string | No | Parent task ID | - -**Example:** -```typescript -task_create({ - subject: "Implement user authentication", - description: "Add JWT-based auth to API endpoints", - blockedBy: ["T-abc123"] // Wait for database migration -}) -``` - -**Returns:** `{ task: { id, subject } }` - -### task_list - -List all active tasks with summary information. - -**Args:** None - -**Returns:** Array of task summaries with id, subject, status, owner, blockedBy. Excludes completed and deleted tasks. The blockedBy field is filtered to only include unresolved (non-completed) blockers. - -**Example:** -```typescript -task_list() // Returns all active tasks -``` - -**Response includes reminder:** "1 task = 1 task. Maximize parallel execution by running independent tasks (tasks with empty blockedBy) concurrently." - -### task_get - -Retrieve a full task object by ID. - -**Args:** -| Arg | Type | Required | Description | -|-----|------|----------|-------------| -| `id` | string | Yes | Task ID (format: T-{uuid}) | - -**Example:** -```typescript -task_get({ id: "T-2a200c59-1a36-4dad-a9c3-3064d180f694" }) -``` - -**Returns:** `{ task: TaskObject | null }` with all fields: id, subject, description, status, activeForm, blocks, blockedBy, owner, metadata, repoURL, parentID, threadID. - -### task_update - -Update an existing task with new values. Supports additive updates for dependencies. - -**Args:** -| Arg | Type | Required | Description | -|-----|------|----------|-------------| -| `id` | string | Yes | Task ID to update | -| `subject` | string | No | New subject | -| `description` | string | No | New description | -| `status` | "pending" \| "in_progress" \| "completed" \| "deleted" | No | Task status | -| `activeForm` | string | No | Active form (present continuous) | -| `owner` | string | No | Task owner (agent name) | -| `addBlocks` | string[] | No | Task IDs to add to blocks (additive) | -| `addBlockedBy` | string[] | No | Task IDs to add to blockedBy (additive) | -| `metadata` | Record | No | Metadata to merge (set key to null to delete) | - -**Example:** -```typescript -task_update({ - id: "T-2a200c59-1a36-4dad-a9c3-3064d180f694", - status: "completed" -}) - -// Add dependencies -task_update({ - id: "T-2a200c59-1a36-4dad-a9c3-3064d180f694", - addBlockedBy: ["T-other-task"] -}) -``` - -**Returns:** `{ task: TaskObject }` with full updated task. - -**Dependency Management:** Use `addBlockedBy` to declare dependencies on other tasks. Properly managed dependencies enable maximum parallel execution. +| `look_at` | System | Factory | Multimodal PDF/image analysis via dedicated agent | +| `skill` | Skill | Factory | Load skill instructions with MCP support | +| `skill_mcp` | Skill | Factory | Call MCP tools/resources/prompts from skill-embedded servers | +| `slashcommand` | Command | Factory | Slash command dispatch with argument substitution | ## DELEGATION SYSTEM (delegate-task) -8 built-in categories: `visual-engineering`, `ultrabrain`, `deep`, `artistry`, `quick`, `unspecified-low`, `unspecified-high`, `writing` +8 built-in categories with domain-optimized models: -Each category defines: model, variant, temperature, max tokens, thinking/reasoning config, prompt append, stability flag. +| Category | Model | Domain | +|----------|-------|--------| +| `visual-engineering` | gemini-3-pro | UI/UX, design, styling | +| `ultrabrain` | gpt-5.3-codex xhigh | Deep logic, architecture | +| `deep` | gpt-5.3-codex medium | Autonomous problem-solving | +| `artistry` | gemini-3-pro high | Creative, unconventional | +| `quick` | claude-haiku-4-5 | Trivial tasks | +| `unspecified-low` | claude-sonnet-4-5 | Moderate effort | +| `unspecified-high` | claude-opus-4-6 max | High effort | +| `writing` | kimi-k2p5 | Documentation, prose | ## HOW TO ADD diff --git a/src/tools/background-task/create-background-task.test.ts b/src/tools/background-task/create-background-task.test.ts index 5cfd07c4..2afc20a0 100644 --- a/src/tools/background-task/create-background-task.test.ts +++ b/src/tools/background-task/create-background-task.test.ts @@ -1,20 +1,32 @@ +/// + import { describe, test, expect, mock } from "bun:test" import type { BackgroundManager } from "../../features/background-agent" +import type { PluginInput } from "@opencode-ai/plugin" import { createBackgroundTask } from "./create-background-task" describe("createBackgroundTask", () => { + const launchMock = mock(() => Promise.resolve({ + id: "test-task-id", + sessionID: null, + description: "Test task", + agent: "test-agent", + status: "pending", + })) + const getTaskMock = mock() + const mockManager = { - launch: mock(() => Promise.resolve({ - id: "test-task-id", - sessionID: null, - description: "Test task", - agent: "test-agent", - status: "pending", - })), - getTask: mock(), + launch: launchMock, + getTask: getTaskMock, } as unknown as BackgroundManager - const tool = createBackgroundTask(mockManager) + const mockClient = { + session: { + messages: mock(() => Promise.resolve({ data: [] })), + }, + } as unknown as PluginInput["client"] + + const tool = createBackgroundTask(mockManager, mockClient) const testContext = { sessionID: "test-session", @@ -31,14 +43,14 @@ describe("createBackgroundTask", () => { test("detects interrupted task as failure", async () => { //#given - mockManager.launch.mockResolvedValueOnce({ + launchMock.mockResolvedValueOnce({ id: "test-task-id", sessionID: null, description: "Test task", agent: "test-agent", status: "pending", }) - mockManager.getTask.mockReturnValueOnce({ + getTaskMock.mockReturnValueOnce({ id: "test-task-id", sessionID: null, description: "Test task", @@ -53,4 +65,4 @@ describe("createBackgroundTask", () => { expect(result).toContain("Task entered error state") expect(result).toContain("test-task-id") }) -}) \ No newline at end of file +}) diff --git a/src/tools/background-task/create-background-task.ts b/src/tools/background-task/create-background-task.ts index a7a365d2..9da0d5c5 100644 --- a/src/tools/background-task/create-background-task.ts +++ b/src/tools/background-task/create-background-task.ts @@ -1,8 +1,8 @@ -import { tool, type ToolDefinition } from "@opencode-ai/plugin" +import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin" import type { BackgroundManager } from "../../features/background-agent" import type { BackgroundTaskArgs } from "./types" import { BACKGROUND_TASK_DESCRIPTION } from "./constants" -import { findFirstMessageWithAgent, findNearestMessageWithFields } from "../../features/hook-message-injector" +import { resolveMessageContext } from "../../features/hook-message-injector" import { getSessionAgent } from "../../features/claude-code-session-state" import { storeToolMetadata } from "../../features/tool-metadata-store" import { log } from "../../shared/logger" @@ -18,7 +18,10 @@ type ToolContextWithMetadata = { callID?: string } -export function createBackgroundTask(manager: BackgroundManager): ToolDefinition { +export function createBackgroundTask( + manager: BackgroundManager, + client: PluginInput["client"] +): ToolDefinition { return tool({ description: BACKGROUND_TASK_DESCRIPTION, args: { @@ -35,8 +38,12 @@ export function createBackgroundTask(manager: BackgroundManager): ToolDefinition try { const messageDir = getMessageDir(ctx.sessionID) - const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null - const firstMessageAgent = messageDir ? findFirstMessageWithAgent(messageDir) : null + const { prevMessage, firstMessageAgent } = await resolveMessageContext( + ctx.sessionID, + client, + messageDir + ) + const sessionAgent = getSessionAgent(ctx.sessionID) const parentAgent = ctx.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent diff --git a/src/tools/background-task/message-dir.ts b/src/tools/background-task/message-dir.ts index 74c49607..a9111f43 100644 --- a/src/tools/background-task/message-dir.ts +++ b/src/tools/background-task/message-dir.ts @@ -1,17 +1 @@ -import { existsSync, readdirSync } from "node:fs" -import { join } from "node:path" -import { MESSAGE_STORAGE } from "../../features/hook-message-injector" - -export function getMessageDir(sessionID: string): string | null { - if (!existsSync(MESSAGE_STORAGE)) return null - - const directPath = join(MESSAGE_STORAGE, sessionID) - if (existsSync(directPath)) return directPath - - for (const dir of readdirSync(MESSAGE_STORAGE)) { - const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) - if (existsSync(sessionPath)) return sessionPath - } - - return null -} +export { getMessageDir } from "../../shared/opencode-message-dir" diff --git a/src/tools/background-task/modules/utils.ts b/src/tools/background-task/modules/utils.ts index bfc14c63..907f8eaf 100644 --- a/src/tools/background-task/modules/utils.ts +++ b/src/tools/background-task/modules/utils.ts @@ -1,20 +1,6 @@ -import { existsSync, readdirSync } from "node:fs" -import { join } from "node:path" -import { MESSAGE_STORAGE } from "../../../features/hook-message-injector" +import { getMessageDir } from "../../../shared" -export function getMessageDir(sessionID: string): string | null { - if (!existsSync(MESSAGE_STORAGE)) return null - - const directPath = join(MESSAGE_STORAGE, sessionID) - if (existsSync(directPath)) return directPath - - for (const dir of readdirSync(MESSAGE_STORAGE)) { - const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) - if (existsSync(sessionPath)) return sessionPath - } - - return null -} +export { getMessageDir } export function formatDuration(start: Date, end?: Date): string { const duration = (end ?? new Date()).getTime() - start.getTime() diff --git a/src/tools/call-omo-agent/background-agent-executor.test.ts b/src/tools/call-omo-agent/background-agent-executor.test.ts index 2c080e7e..d27575c1 100644 --- a/src/tools/call-omo-agent/background-agent-executor.test.ts +++ b/src/tools/call-omo-agent/background-agent-executor.test.ts @@ -1,17 +1,22 @@ +/// import { describe, test, expect, mock } from "bun:test" import type { BackgroundManager } from "../../features/background-agent" +import type { PluginInput } from "@opencode-ai/plugin" import { executeBackgroundAgent } from "./background-agent-executor" describe("executeBackgroundAgent", () => { + const launchMock = mock(() => Promise.resolve({ + id: "test-task-id", + sessionID: null, + description: "Test task", + agent: "test-agent", + status: "pending", + })) + const getTaskMock = mock() + const mockManager = { - launch: mock(() => Promise.resolve({ - id: "test-task-id", - sessionID: null, - description: "Test task", - agent: "test-agent", - status: "pending", - })), - getTask: mock(), + launch: launchMock, + getTask: getTaskMock, } as unknown as BackgroundManager const testContext = { @@ -25,18 +30,25 @@ describe("executeBackgroundAgent", () => { description: "Test background task", prompt: "Test prompt", subagent_type: "test-agent", + run_in_background: true, } + const mockClient = { + session: { + messages: mock(() => Promise.resolve({ data: [] })), + }, + } as unknown as PluginInput["client"] + test("detects interrupted task as failure", async () => { //#given - mockManager.launch.mockResolvedValueOnce({ + launchMock.mockResolvedValueOnce({ id: "test-task-id", sessionID: null, description: "Test task", agent: "test-agent", status: "pending", }) - mockManager.getTask.mockReturnValueOnce({ + getTaskMock.mockReturnValueOnce({ id: "test-task-id", sessionID: null, description: "Test task", @@ -45,11 +57,11 @@ describe("executeBackgroundAgent", () => { }) //#when - const result = await executeBackgroundAgent(testArgs, testContext, mockManager) + const result = await executeBackgroundAgent(testArgs, testContext, mockManager, mockClient) //#then expect(result).toContain("Task failed to start") expect(result).toContain("interrupt") expect(result).toContain("test-task-id") }) -}) \ No newline at end of file +}) diff --git a/src/tools/call-omo-agent/background-agent-executor.ts b/src/tools/call-omo-agent/background-agent-executor.ts index 7babb43c..c09f78df 100644 --- a/src/tools/call-omo-agent/background-agent-executor.ts +++ b/src/tools/call-omo-agent/background-agent-executor.ts @@ -1,5 +1,6 @@ import type { BackgroundManager } from "../../features/background-agent" -import { findFirstMessageWithAgent, findNearestMessageWithFields } from "../../features/hook-message-injector" +import type { PluginInput } from "@opencode-ai/plugin" +import { resolveMessageContext } from "../../features/hook-message-injector" import { getSessionAgent } from "../../features/claude-code-session-state" import { log } from "../../shared" import type { CallOmoAgentArgs } from "./types" @@ -11,11 +12,16 @@ export async function executeBackgroundAgent( args: CallOmoAgentArgs, toolContext: ToolContextWithMetadata, manager: BackgroundManager, + client: PluginInput["client"], ): Promise { try { const messageDir = getMessageDir(toolContext.sessionID) - const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null - const firstMessageAgent = messageDir ? findFirstMessageWithAgent(messageDir) : null + const { prevMessage, firstMessageAgent } = await resolveMessageContext( + toolContext.sessionID, + client, + messageDir + ) + const sessionAgent = getSessionAgent(toolContext.sessionID) const parentAgent = toolContext.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent diff --git a/src/tools/call-omo-agent/background-executor.test.ts b/src/tools/call-omo-agent/background-executor.test.ts index 8323c651..970b9c13 100644 --- a/src/tools/call-omo-agent/background-executor.test.ts +++ b/src/tools/call-omo-agent/background-executor.test.ts @@ -1,17 +1,22 @@ +/// import { describe, test, expect, mock } from "bun:test" import type { BackgroundManager } from "../../features/background-agent" +import type { PluginInput } from "@opencode-ai/plugin" import { executeBackground } from "./background-executor" describe("executeBackground", () => { + const launchMock = mock(() => Promise.resolve({ + id: "test-task-id", + sessionID: null, + description: "Test task", + agent: "test-agent", + status: "pending", + })) + const getTaskMock = mock() + const mockManager = { - launch: mock(() => Promise.resolve({ - id: "test-task-id", - sessionID: null, - description: "Test task", - agent: "test-agent", - status: "pending", - })), - getTask: mock(), + launch: launchMock, + getTask: getTaskMock, } as unknown as BackgroundManager const testContext = { @@ -25,18 +30,25 @@ describe("executeBackground", () => { description: "Test background task", prompt: "Test prompt", subagent_type: "test-agent", + run_in_background: true, } + const mockClient = { + session: { + messages: mock(() => Promise.resolve({ data: [] })), + }, + } as unknown as PluginInput["client"] + test("detects interrupted task as failure", async () => { //#given - mockManager.launch.mockResolvedValueOnce({ + launchMock.mockResolvedValueOnce({ id: "test-task-id", sessionID: null, description: "Test task", agent: "test-agent", status: "pending", }) - mockManager.getTask.mockReturnValueOnce({ + getTaskMock.mockReturnValueOnce({ id: "test-task-id", sessionID: null, description: "Test task", @@ -45,11 +57,11 @@ describe("executeBackground", () => { }) //#when - const result = await executeBackground(testArgs, testContext, mockManager) + const result = await executeBackground(testArgs, testContext, mockManager, mockClient) //#then expect(result).toContain("Task failed to start") expect(result).toContain("interrupt") expect(result).toContain("test-task-id") }) -}) \ No newline at end of file +}) diff --git a/src/tools/call-omo-agent/background-executor.ts b/src/tools/call-omo-agent/background-executor.ts index 5751664a..c9eb9ef4 100644 --- a/src/tools/call-omo-agent/background-executor.ts +++ b/src/tools/call-omo-agent/background-executor.ts @@ -1,8 +1,9 @@ import type { CallOmoAgentArgs } from "./types" import type { BackgroundManager } from "../../features/background-agent" +import type { PluginInput } from "@opencode-ai/plugin" import { log } from "../../shared" import { consumeNewMessages } from "../../shared/session-cursor" -import { findFirstMessageWithAgent, findNearestMessageWithFields } from "../../features/hook-message-injector" +import { resolveMessageContext } from "../../features/hook-message-injector" import { getSessionAgent } from "../../features/claude-code-session-state" import { getMessageDir } from "./message-dir" import { getSessionTools } from "../../shared/session-tools-store" @@ -16,12 +17,17 @@ export async function executeBackground( abort: AbortSignal metadata?: (input: { title?: string; metadata?: Record }) => void }, - manager: BackgroundManager + manager: BackgroundManager, + client: PluginInput["client"] ): Promise { try { const messageDir = getMessageDir(toolContext.sessionID) - const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null - const firstMessageAgent = messageDir ? findFirstMessageWithAgent(messageDir) : null + const { prevMessage, firstMessageAgent } = await resolveMessageContext( + toolContext.sessionID, + client, + messageDir + ) + const sessionAgent = getSessionAgent(toolContext.sessionID) const parentAgent = toolContext.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent diff --git a/src/tools/call-omo-agent/completion-poller.ts b/src/tools/call-omo-agent/completion-poller.ts index 0ca73e73..61f2829b 100644 --- a/src/tools/call-omo-agent/completion-poller.ts +++ b/src/tools/call-omo-agent/completion-poller.ts @@ -1,5 +1,6 @@ import type { PluginInput } from "@opencode-ai/plugin" import { log } from "../../shared" +import { normalizeSDKResponse } from "../../shared" export async function waitForCompletion( sessionID: string, @@ -33,7 +34,7 @@ export async function waitForCompletion( // Check session status const statusResult = await ctx.client.session.status() - const allStatuses = (statusResult.data ?? {}) as Record + const allStatuses = normalizeSDKResponse(statusResult, {} as Record) const sessionStatus = allStatuses[sessionID] // If session is actively running, reset stability counter @@ -45,7 +46,9 @@ export async function waitForCompletion( // Session is idle - check message stability const messagesCheck = await ctx.client.session.messages({ path: { id: sessionID } }) - const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array + const msgs = normalizeSDKResponse(messagesCheck, [] as Array, { + preferResponseOnMissingData: true, + }) const currentMsgCount = msgs.length if (currentMsgCount > 0 && currentMsgCount === lastMsgCount) { diff --git a/src/tools/call-omo-agent/message-dir.ts b/src/tools/call-omo-agent/message-dir.ts index 01fa68fc..a9111f43 100644 --- a/src/tools/call-omo-agent/message-dir.ts +++ b/src/tools/call-omo-agent/message-dir.ts @@ -1,18 +1 @@ -import { existsSync, readdirSync } from "node:fs" -import { join } from "node:path" -import { MESSAGE_STORAGE } from "../../features/hook-message-injector" - -export function getMessageDir(sessionID: string): string | null { - if (!sessionID.startsWith("ses_")) return null - if (!existsSync(MESSAGE_STORAGE)) return null - - const directPath = join(MESSAGE_STORAGE, sessionID) - if (existsSync(directPath)) return directPath - - for (const dir of readdirSync(MESSAGE_STORAGE)) { - const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) - if (existsSync(sessionPath)) return sessionPath - } - - return null -} +export { getMessageDir } from "../../shared/opencode-message-dir" diff --git a/src/tools/call-omo-agent/message-storage-directory.ts b/src/tools/call-omo-agent/message-storage-directory.ts index 30fecd6e..cf8b56ed 100644 --- a/src/tools/call-omo-agent/message-storage-directory.ts +++ b/src/tools/call-omo-agent/message-storage-directory.ts @@ -1,18 +1 @@ -import { existsSync, readdirSync } from "node:fs" -import { join } from "node:path" -import { MESSAGE_STORAGE } from "../../features/hook-message-injector" - -export function getMessageDir(sessionID: string): string | null { - if (!sessionID.startsWith("ses_")) return null - if (!existsSync(MESSAGE_STORAGE)) return null - - const directPath = join(MESSAGE_STORAGE, sessionID) - if (existsSync(directPath)) return directPath - - for (const dir of readdirSync(MESSAGE_STORAGE)) { - const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) - if (existsSync(sessionPath)) return sessionPath - } - - return null -} +export { getMessageDir } from "../../shared" diff --git a/src/tools/call-omo-agent/tools.ts b/src/tools/call-omo-agent/tools.ts index dbcfcf97..b773d21a 100644 --- a/src/tools/call-omo-agent/tools.ts +++ b/src/tools/call-omo-agent/tools.ts @@ -48,7 +48,7 @@ export function createCallOmoAgent( if (args.session_id) { return `Error: session_id is not supported in background mode. Use run_in_background=false to continue an existing session.` } - return await executeBackground(args, toolCtx, backgroundManager) + return await executeBackground(args, toolCtx, backgroundManager, ctx.client) } return await executeSync(args, toolCtx, ctx) diff --git a/src/tools/delegate-task/parent-context-resolver.ts b/src/tools/delegate-task/parent-context-resolver.ts index cf231783..2d831cda 100644 --- a/src/tools/delegate-task/parent-context-resolver.ts +++ b/src/tools/delegate-task/parent-context-resolver.ts @@ -1,14 +1,22 @@ import type { ToolContextWithMetadata } from "./types" +import type { OpencodeClient } from "./types" import type { ParentContext } from "./executor-types" -import { findNearestMessageWithFields, findFirstMessageWithAgent } from "../../features/hook-message-injector" +import { resolveMessageContext } from "../../features/hook-message-injector" import { getSessionAgent } from "../../features/claude-code-session-state" import { log } from "../../shared/logger" -import { getMessageDir } from "../../shared/session-utils" +import { getMessageDir } from "../../shared/opencode-message-dir" -export function resolveParentContext(ctx: ToolContextWithMetadata): ParentContext { +export async function resolveParentContext( + ctx: ToolContextWithMetadata, + client: OpencodeClient +): Promise { const messageDir = getMessageDir(ctx.sessionID) - const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null - const firstMessageAgent = messageDir ? findFirstMessageWithAgent(messageDir) : null + const { prevMessage, firstMessageAgent } = await resolveMessageContext( + ctx.sessionID, + client, + messageDir + ) + const sessionAgent = getSessionAgent(ctx.sessionID) const parentAgent = ctx.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent diff --git a/src/tools/delegate-task/subagent-resolver.ts b/src/tools/delegate-task/subagent-resolver.ts index 0447416d..5651fba2 100644 --- a/src/tools/delegate-task/subagent-resolver.ts +++ b/src/tools/delegate-task/subagent-resolver.ts @@ -4,6 +4,8 @@ import { isPlanFamily } from "./constants" import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent" import { parseModelString } from "./model-string-parser" import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements" +import { getAgentDisplayName, getAgentConfigKey } from "../../shared/agent-display-names" +import { normalizeSDKResponse } from "../../shared" import { getAvailableModelsForDelegateTask } from "./available-models" import { resolveModelForDelegateTask } from "./model-selection" @@ -47,17 +49,22 @@ Create the work plan directly - that's your job as the planning agent.`, try { const agentsResult = await client.app.agents() type AgentInfo = { name: string; mode?: "subagent" | "primary" | "all"; model?: { providerID: string; modelID: string } } - const agents = (agentsResult as { data?: AgentInfo[] }).data ?? agentsResult as unknown as AgentInfo[] + const agents = normalizeSDKResponse(agentsResult, [] as AgentInfo[], { + preferResponseOnMissingData: true, + }) const callableAgents = agents.filter((a) => a.mode !== "primary") + const resolvedDisplayName = getAgentDisplayName(agentToUse) const matchedAgent = callableAgents.find( (agent) => agent.name.toLowerCase() === agentToUse.toLowerCase() + || agent.name.toLowerCase() === resolvedDisplayName.toLowerCase() ) if (!matchedAgent) { const isPrimaryAgent = agents .filter((a) => a.mode === "primary") - .find((agent) => agent.name.toLowerCase() === agentToUse.toLowerCase()) + .find((agent) => agent.name.toLowerCase() === agentToUse.toLowerCase() + || agent.name.toLowerCase() === resolvedDisplayName.toLowerCase()) if (isPrimaryAgent) { return { @@ -80,10 +87,10 @@ Create the work plan directly - that's your job as the planning agent.`, agentToUse = matchedAgent.name - const agentNameLower = agentToUse.toLowerCase() - const agentOverride = agentOverrides?.[agentNameLower as keyof typeof agentOverrides] - ?? (agentOverrides ? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentNameLower)?.[1] : undefined) - const agentRequirement = AGENT_MODEL_REQUIREMENTS[agentNameLower] + const agentConfigKey = getAgentConfigKey(agentToUse) + const agentOverride = agentOverrides?.[agentConfigKey as keyof typeof agentOverrides] + ?? (agentOverrides ? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentConfigKey)?.[1] : undefined) + const agentRequirement = AGENT_MODEL_REQUIREMENTS[agentConfigKey] if (agentOverride?.model || agentRequirement || matchedAgent.model) { const availableModels = await getAvailableModelsForDelegateTask(client) diff --git a/src/tools/delegate-task/sync-continuation.ts b/src/tools/delegate-task/sync-continuation.ts index 72355982..b31e1950 100644 --- a/src/tools/delegate-task/sync-continuation.ts +++ b/src/tools/delegate-task/sync-continuation.ts @@ -4,12 +4,13 @@ import { isPlanFamily } from "./constants" import { storeToolMetadata } from "../../features/tool-metadata-store" import { getTaskToastManager } from "../../features/task-toast-manager" import { getAgentToolRestrictions } from "../../shared/agent-tool-restrictions" -import { getMessageDir } from "../../shared/session-utils" +import { getMessageDir } from "../../shared" import { promptWithModelSuggestionRetry } from "../../shared/model-suggestion-retry" import { findNearestMessageWithFields } from "../../features/hook-message-injector" import { formatDuration } from "./time-formatter" import { syncContinuationDeps, type SyncContinuationDeps } from "./sync-continuation-deps" import { setSessionTools } from "../../shared/session-tools-store" +import { normalizeSDKResponse } from "../../shared" export async function executeSyncContinuation( args: DelegateTaskArgs, @@ -56,7 +57,7 @@ export async function executeSyncContinuation( try { try { const messagesResp = await client.session.messages({ path: { id: args.session_id! } }) - const messages = (messagesResp.data ?? []) as SessionMessage[] + const messages = normalizeSDKResponse(messagesResp, [] as SessionMessage[]) anchorMessageCount = messages.length for (let i = messages.length - 1; i >= 0; i--) { const info = messages[i].info diff --git a/src/tools/delegate-task/sync-result-fetcher.ts b/src/tools/delegate-task/sync-result-fetcher.ts index 64d1a278..3eb454e5 100644 --- a/src/tools/delegate-task/sync-result-fetcher.ts +++ b/src/tools/delegate-task/sync-result-fetcher.ts @@ -1,5 +1,6 @@ import type { OpencodeClient } from "./types" import type { SessionMessage } from "./executor-types" +import { normalizeSDKResponse } from "../../shared" export async function fetchSyncResult( client: OpencodeClient, @@ -14,7 +15,9 @@ export async function fetchSyncResult( return { ok: false, error: `Error fetching result: ${(messagesResult as { error: unknown }).error}\n\nSession ID: ${sessionID}` } } - const messages = ((messagesResult as { data?: unknown }).data ?? messagesResult) as SessionMessage[] + const messages = normalizeSDKResponse(messagesResult, [] as SessionMessage[], { + preferResponseOnMissingData: true, + }) const messagesAfterAnchor = anchorMessageCount !== undefined ? messages.slice(anchorMessageCount) : messages diff --git a/src/tools/delegate-task/sync-session-poller.ts b/src/tools/delegate-task/sync-session-poller.ts index 3f7b2fd9..9c8cb256 100644 --- a/src/tools/delegate-task/sync-session-poller.ts +++ b/src/tools/delegate-task/sync-session-poller.ts @@ -2,6 +2,7 @@ import type { ToolContextWithMetadata, OpencodeClient } from "./types" import type { SessionMessage } from "./executor-types" import { getTimingConfig } from "./timing" import { log } from "../../shared/logger" +import { normalizeSDKResponse } from "../../shared" const NON_TERMINAL_FINISH_REASONS = new Set(["tool-calls", "unknown"]) @@ -58,7 +59,7 @@ export async function pollSyncSession( log("[task] Poll status fetch failed, retrying", { sessionID: input.sessionID, error: String(error) }) continue } - const allStatuses = (statusResult.data ?? {}) as Record + const allStatuses = normalizeSDKResponse(statusResult, {} as Record) const sessionStatus = allStatuses[input.sessionID] if (pollCount % 10 === 0) { diff --git a/src/tools/delegate-task/tools.test.ts b/src/tools/delegate-task/tools.test.ts index 8c4c5735..c2cfff1a 100644 --- a/src/tools/delegate-task/tools.test.ts +++ b/src/tools/delegate-task/tools.test.ts @@ -1267,52 +1267,58 @@ describe("sisyphus-task", () => { launch: async () => mockTask, } - let messagesCallCount = 0 + let messagesCallCount = 0 - const mockClient = { - session: { - prompt: async () => ({ data: {} }), - promptAsync: async () => ({ data: {} }), - messages: async () => { - messagesCallCount++ - const now = Date.now() + const mockClient = { + session: { + prompt: async () => ({ data: {} }), + promptAsync: async () => ({ data: {} }), + messages: async (args?: { path?: { id?: string } }) => { + const sessionID = args?.path?.id + // Only track calls for the target session (ses_continue_test), + // not for parent-session calls from resolveParentContext + if (sessionID !== "ses_continue_test") { + return { data: [] } + } + messagesCallCount++ + const now = Date.now() - const beforeContinuation = [ - { - info: { id: "msg_001", role: "user", time: { created: now } }, - parts: [{ type: "text", text: "Previous context" }], - }, - { - info: { id: "msg_002", role: "assistant", time: { created: now + 1 }, finish: "end_turn" }, - parts: [{ type: "text", text: "Previous result" }], - }, - ] + const beforeContinuation = [ + { + info: { id: "msg_001", role: "user", time: { created: now } }, + parts: [{ type: "text", text: "Previous context" }], + }, + { + info: { id: "msg_002", role: "assistant", time: { created: now + 1 }, finish: "end_turn" }, + parts: [{ type: "text", text: "Previous result" }], + }, + ] - if (messagesCallCount === 1) { - return { data: beforeContinuation } - } + if (messagesCallCount === 1) { + return { data: beforeContinuation } + } - return { - data: [ - ...beforeContinuation, - { - info: { id: "msg_003", role: "user", time: { created: now + 2 } }, - parts: [{ type: "text", text: "Continue the task" }], - }, - { - info: { id: "msg_004", role: "assistant", time: { created: now + 3 }, finish: "end_turn" }, - parts: [{ type: "text", text: "This is the continued task result" }], - }, - ], - } - }, - status: async () => ({ data: { "ses_continue_test": { type: "idle" } } }), + return { + data: [ + ...beforeContinuation, + { + info: { id: "msg_003", role: "user", time: { created: now + 2 } }, + parts: [{ type: "text", text: "Continue the task" }], + }, + { + info: { id: "msg_004", role: "assistant", time: { created: now + 3 }, finish: "end_turn" }, + parts: [{ type: "text", text: "This is the continued task result" }], + }, + ], + } + }, + status: async () => ({ data: { "ses_continue_test": { type: "idle" } } }), + }, + config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) }, + app: { + agents: async () => ({ data: [] }), }, - config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) }, - app: { - agents: async () => ({ data: [] }), - }, - } + } const tool = createDelegateTask({ manager: mockManager, @@ -1714,17 +1720,19 @@ describe("sisyphus-task", () => { const { createDelegateTask } = require("./tools") let launchCalled = false + const launchedTask = { + id: "task-unstable", + sessionID: "ses_unstable_gemini", + description: "Unstable gemini task", + agent: "sisyphus-junior", + status: "running", + } const mockManager = { launch: async () => { launchCalled = true - return { - id: "task-unstable", - sessionID: "ses_unstable_gemini", - description: "Unstable gemini task", - agent: "sisyphus-junior", - status: "running", - } + return launchedTask }, + getTask: () => launchedTask, } const mockClient = { @@ -1839,17 +1847,19 @@ describe("sisyphus-task", () => { const { createDelegateTask } = require("./tools") let launchCalled = false + const launchedTask = { + id: "task-unstable-minimax", + sessionID: "ses_unstable_minimax", + description: "Unstable minimax task", + agent: "sisyphus-junior", + status: "running", + } const mockManager = { launch: async () => { launchCalled = true - return { - id: "task-unstable-minimax", - sessionID: "ses_unstable_minimax", - description: "Unstable minimax task", - agent: "sisyphus-junior", - status: "running", - } + return launchedTask }, + getTask: () => launchedTask, } const mockClient = { @@ -1973,17 +1983,19 @@ describe("sisyphus-task", () => { const { createDelegateTask } = require("./tools") let launchCalled = false + const launchedTask = { + id: "task-artistry", + sessionID: "ses_artistry_gemini", + description: "Artistry gemini task", + agent: "sisyphus-junior", + status: "running", + } const mockManager = { launch: async () => { launchCalled = true - return { - id: "task-artistry", - sessionID: "ses_artistry_gemini", - description: "Artistry gemini task", - agent: "sisyphus-junior", - status: "running", - } + return launchedTask }, + getTask: () => launchedTask, } const mockClient = { @@ -2039,17 +2051,19 @@ describe("sisyphus-task", () => { const { createDelegateTask } = require("./tools") let launchCalled = false + const launchedTask = { + id: "task-writing", + sessionID: "ses_writing_gemini", + description: "Writing gemini task", + agent: "sisyphus-junior", + status: "running", + } const mockManager = { launch: async () => { launchCalled = true - return { - id: "task-writing", - sessionID: "ses_writing_gemini", - description: "Writing gemini task", - agent: "sisyphus-junior", - status: "running", - } + return launchedTask }, + getTask: () => launchedTask, } const mockClient = { @@ -2105,17 +2119,19 @@ describe("sisyphus-task", () => { const { createDelegateTask } = require("./tools") let launchCalled = false + const launchedTask = { + id: "task-custom-unstable", + sessionID: "ses_custom_unstable", + description: "Custom unstable task", + agent: "sisyphus-junior", + status: "running", + } const mockManager = { launch: async () => { launchCalled = true - return { - id: "task-custom-unstable", - sessionID: "ses_custom_unstable", - description: "Custom unstable task", - agent: "sisyphus-junior", - status: "running", - } + return launchedTask }, + getTask: () => launchedTask, } const mockClient = { diff --git a/src/tools/delegate-task/tools.ts b/src/tools/delegate-task/tools.ts index cfa01ebe..763b09f0 100644 --- a/src/tools/delegate-task/tools.ts +++ b/src/tools/delegate-task/tools.ts @@ -129,7 +129,7 @@ Prompts MUST be in English.` return skillError } - const parentContext = resolveParentContext(ctx) + const parentContext = await resolveParentContext(ctx, options.client) if (args.session_id) { if (runInBackground) { diff --git a/src/tools/delegate-task/unstable-agent-task.test.ts b/src/tools/delegate-task/unstable-agent-task.test.ts new file mode 100644 index 00000000..de5de840 --- /dev/null +++ b/src/tools/delegate-task/unstable-agent-task.test.ts @@ -0,0 +1,224 @@ +const { describe, test, expect, beforeEach, afterEach, mock } = require("bun:test") + +describe("executeUnstableAgentTask - interrupt detection", () => { + beforeEach(() => { + //#given - configure fast timing for all tests + const { __setTimingConfig } = require("./timing") + __setTimingConfig({ + POLL_INTERVAL_MS: 10, + MIN_STABILITY_TIME_MS: 0, + STABILITY_POLLS_REQUIRED: 1, + MAX_POLL_TIME_MS: 500, + WAIT_FOR_SESSION_TIMEOUT_MS: 100, + WAIT_FOR_SESSION_INTERVAL_MS: 10, + }) + }) + + afterEach(() => { + //#given - reset timing after each test + const { __resetTimingConfig } = require("./timing") + __resetTimingConfig() + mock.restore() + }) + + test("should return error immediately when background task becomes interrupted during polling", async () => { + //#given - a background task that gets interrupted on first poll check + const taskState = { + id: "bg_test_interrupt", + sessionID: "ses_test_interrupt", + status: "interrupt" as string, + description: "test interrupted task", + prompt: "test prompt", + agent: "sisyphus-junior", + error: "Agent not found" as string | undefined, + } + + const launchState = { ...taskState, status: "running" as string, error: undefined as string | undefined } + + const mockManager = { + launch: async () => launchState, + getTask: () => taskState, + } + + const mockClient = { + session: { + status: async () => ({ data: { [taskState.sessionID!]: { type: "idle" } } }), + messages: async () => ({ data: [] }), + }, + } + + const { executeUnstableAgentTask } = require("./unstable-agent-task") + + const args = { + prompt: "test prompt", + description: "test task", + category: "test", + load_skills: [], + run_in_background: false, + } + + const mockCtx = { + sessionID: "parent-session", + callID: "call-123", + metadata: () => {}, + } + + const mockExecutorCtx = { + manager: mockManager, + client: mockClient, + directory: "/tmp", + } + + const parentContext = { + sessionID: "parent-session", + messageID: "msg-123", + } + + //#when - executeUnstableAgentTask encounters an interrupted task + const startTime = Date.now() + const result = await executeUnstableAgentTask( + args, mockCtx, mockExecutorCtx, parentContext, + "test-agent", undefined, undefined, "test-model" + ) + const elapsed = Date.now() - startTime + + //#then - should return quickly with interrupt error, not hang until MAX_POLL_TIME_MS + expect(result).toContain("interrupt") + expect(result.toLowerCase()).toContain("agent not found") + expect(elapsed).toBeLessThan(400) + }) + + test("should return error immediately when background task becomes errored during polling", async () => { + //#given - a background task that is already errored when poll checks + const taskState = { + id: "bg_test_error", + sessionID: "ses_test_error", + status: "error" as string, + description: "test error task", + prompt: "test prompt", + agent: "sisyphus-junior", + error: "Rate limit exceeded" as string | undefined, + } + + const launchState = { ...taskState, status: "running" as string, error: undefined as string | undefined } + + const mockManager = { + launch: async () => launchState, + getTask: () => taskState, + } + + const mockClient = { + session: { + status: async () => ({ data: { [taskState.sessionID!]: { type: "idle" } } }), + messages: async () => ({ data: [] }), + }, + } + + const { executeUnstableAgentTask } = require("./unstable-agent-task") + + const args = { + prompt: "test prompt", + description: "test task", + category: "test", + load_skills: [], + run_in_background: false, + } + + const mockCtx = { + sessionID: "parent-session", + callID: "call-123", + metadata: () => {}, + } + + const mockExecutorCtx = { + manager: mockManager, + client: mockClient, + directory: "/tmp", + } + + const parentContext = { + sessionID: "parent-session", + messageID: "msg-123", + } + + //#when - executeUnstableAgentTask encounters an errored task + const startTime = Date.now() + const result = await executeUnstableAgentTask( + args, mockCtx, mockExecutorCtx, parentContext, + "test-agent", undefined, undefined, "test-model" + ) + const elapsed = Date.now() - startTime + + //#then - should return quickly with error, not hang until MAX_POLL_TIME_MS + expect(result).toContain("error") + expect(result.toLowerCase()).toContain("rate limit exceeded") + expect(elapsed).toBeLessThan(400) + }) + + test("should return error immediately when background task becomes cancelled during polling", async () => { + //#given - a background task that is already cancelled when poll checks + const taskState = { + id: "bg_test_cancel", + sessionID: "ses_test_cancel", + status: "cancelled" as string, + description: "test cancelled task", + prompt: "test prompt", + agent: "sisyphus-junior", + error: "Stale timeout" as string | undefined, + } + + const launchState = { ...taskState, status: "running" as string, error: undefined as string | undefined } + + const mockManager = { + launch: async () => launchState, + getTask: () => taskState, + } + + const mockClient = { + session: { + status: async () => ({ data: { [taskState.sessionID!]: { type: "idle" } } }), + messages: async () => ({ data: [] }), + }, + } + + const { executeUnstableAgentTask } = require("./unstable-agent-task") + + const args = { + prompt: "test prompt", + description: "test task", + category: "test", + load_skills: [], + run_in_background: false, + } + + const mockCtx = { + sessionID: "parent-session", + callID: "call-123", + metadata: () => {}, + } + + const mockExecutorCtx = { + manager: mockManager, + client: mockClient, + directory: "/tmp", + } + + const parentContext = { + sessionID: "parent-session", + messageID: "msg-123", + } + + //#when - executeUnstableAgentTask encounters a cancelled task + const startTime = Date.now() + const result = await executeUnstableAgentTask( + args, mockCtx, mockExecutorCtx, parentContext, + "test-agent", undefined, undefined, "test-model" + ) + const elapsed = Date.now() - startTime + + //#then - should return quickly with cancel info, not hang until MAX_POLL_TIME_MS + expect(result).toContain("cancel") + expect(result.toLowerCase()).toContain("stale timeout") + expect(elapsed).toBeLessThan(400) + }) +}) diff --git a/src/tools/delegate-task/unstable-agent-task.ts b/src/tools/delegate-task/unstable-agent-task.ts index 9e0bf853..d806fd93 100644 --- a/src/tools/delegate-task/unstable-agent-task.ts +++ b/src/tools/delegate-task/unstable-agent-task.ts @@ -5,6 +5,7 @@ import { storeToolMetadata } from "../../features/tool-metadata-store" import { formatDuration } from "./time-formatter" import { formatDetailedError } from "./error-formatting" import { getSessionTools } from "../../shared/session-tools-store" +import { normalizeSDKResponse } from "../../shared" export async function executeUnstableAgentTask( args: DelegateTaskArgs, @@ -77,6 +78,7 @@ export async function executeUnstableAgentTask( const pollStart = Date.now() let lastMsgCount = 0 let stablePolls = 0 + let terminalStatus: { status: string; error?: string } | undefined while (Date.now() - pollStart < timingCfg.MAX_POLL_TIME_MS) { if (ctx.abort?.aborted) { @@ -85,8 +87,14 @@ export async function executeUnstableAgentTask( await new Promise(resolve => setTimeout(resolve, timingCfg.POLL_INTERVAL_MS)) + const currentTask = manager.getTask(task.id) + if (currentTask && (currentTask.status === "interrupt" || currentTask.status === "error" || currentTask.status === "cancelled")) { + terminalStatus = { status: currentTask.status, error: currentTask.error } + break + } + const statusResult = await client.session.status() - const allStatuses = (statusResult.data ?? {}) as Record + const allStatuses = normalizeSDKResponse(statusResult, {} as Record) const sessionStatus = allStatuses[sessionID] if (sessionStatus && sessionStatus.type !== "idle") { @@ -98,7 +106,9 @@ export async function executeUnstableAgentTask( if (Date.now() - pollStart < timingCfg.MIN_STABILITY_TIME_MS) continue const messagesCheck = await client.session.messages({ path: { id: sessionID } }) - const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array + const msgs = normalizeSDKResponse(messagesCheck, [] as Array, { + preferResponseOnMissingData: true, + }) const currentMsgCount = msgs.length if (currentMsgCount === lastMsgCount) { @@ -110,8 +120,28 @@ export async function executeUnstableAgentTask( } } + if (terminalStatus) { + const duration = formatDuration(startTime) + return `SUPERVISED TASK FAILED (${terminalStatus.status}) + +Task was interrupted/failed while running in monitored background mode. +${terminalStatus.error ? `Error: ${terminalStatus.error}` : ""} + +Duration: ${duration} +Agent: ${agentToUse}${args.category ? ` (category: ${args.category})` : ""} +Model: ${actualModel} + +The task session may contain partial results. + + +session_id: ${sessionID} +` + } + const messagesResult = await client.session.messages({ path: { id: sessionID } }) - const messages = ((messagesResult as { data?: unknown }).data ?? messagesResult) as SessionMessage[] + const messages = normalizeSDKResponse(messagesResult, [] as SessionMessage[], { + preferResponseOnMissingData: true, + }) const assistantMessages = messages .filter((m) => m.info?.role === "assistant") diff --git a/src/tools/session-manager/constants.ts b/src/tools/session-manager/constants.ts index 5f079a1a..cdcb914c 100644 --- a/src/tools/session-manager/constants.ts +++ b/src/tools/session-manager/constants.ts @@ -1,11 +1,7 @@ import { join } from "node:path" -import { getOpenCodeStorageDir } from "../../shared/data-path" import { getClaudeConfigDir } from "../../shared" -export const OPENCODE_STORAGE = getOpenCodeStorageDir() -export const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message") -export const PART_STORAGE = join(OPENCODE_STORAGE, "part") -export const SESSION_STORAGE = join(OPENCODE_STORAGE, "session") +export { OPENCODE_STORAGE, MESSAGE_STORAGE, PART_STORAGE, SESSION_STORAGE } from "../../shared" export const TODO_DIR = join(getClaudeConfigDir(), "todos") export const TRANSCRIPT_DIR = join(getClaudeConfigDir(), "transcripts") export const SESSION_LIST_DESCRIPTION = `List all OpenCode sessions with optional filtering. diff --git a/src/tools/session-manager/session-formatter.ts b/src/tools/session-manager/session-formatter.ts index 33faae9c..f1a359aa 100644 --- a/src/tools/session-manager/session-formatter.ts +++ b/src/tools/session-manager/session-formatter.ts @@ -44,7 +44,7 @@ export async function formatSessionList(sessionIDs: string[]): Promise { export function formatSessionMessages( messages: SessionMessage[], includeTodos?: boolean, - todos?: Array<{ id: string; content: string; status: string }> + todos?: Array<{ id?: string; content: string; status: string }> ): string { if (messages.length === 0) { return "No messages found in this session." diff --git a/src/tools/session-manager/storage.test.ts b/src/tools/session-manager/storage.test.ts index 76507867..63d3eca2 100644 --- a/src/tools/session-manager/storage.test.ts +++ b/src/tools/session-manager/storage.test.ts @@ -26,6 +26,18 @@ mock.module("./constants", () => ({ TOOL_NAME_PREFIX: "session_", })) +mock.module("../../shared/opencode-storage-detection", () => ({ + isSqliteBackend: () => false, + resetSqliteBackendCache: () => {}, +})) + +mock.module("../../shared/opencode-storage-paths", () => ({ + OPENCODE_STORAGE: TEST_DIR, + MESSAGE_STORAGE: TEST_MESSAGE_STORAGE, + PART_STORAGE: TEST_PART_STORAGE, + SESSION_STORAGE: TEST_SESSION_STORAGE, +})) + const { getAllSessions, getMessageDir, sessionExists, readSessionMessages, readSessionTodos, getSessionInfo } = await import("./storage") @@ -73,15 +85,15 @@ describe("session-manager storage", () => { expect(result).toBe(sessionPath) }) - test("sessionExists returns false for non-existent session", () => { + test("sessionExists returns false for non-existent session", async () => { // when - const exists = sessionExists("ses_nonexistent") + const exists = await sessionExists("ses_nonexistent") // then expect(exists).toBe(false) }) - test("sessionExists returns true for existing session", () => { + test("sessionExists returns true for existing session", async () => { // given const sessionID = "ses_exists" const sessionPath = join(TEST_MESSAGE_STORAGE, sessionID) @@ -89,7 +101,7 @@ describe("session-manager storage", () => { writeFileSync(join(sessionPath, "msg_001.json"), JSON.stringify({ id: "msg_001" })) // when - const exists = sessionExists(sessionID) + const exists = await sessionExists(sessionID) // then expect(exists).toBe(true) @@ -314,3 +326,168 @@ describe("session-manager storage - getMainSessions", () => { expect(sessions.length).toBe(2) }) }) + +describe("session-manager storage - SDK path (beta mode)", () => { + const mockClient = { + session: { + list: mock(() => Promise.resolve({ data: [] })), + messages: mock(() => Promise.resolve({ data: [] })), + todo: mock(() => Promise.resolve({ data: [] })), + }, + } + + beforeEach(() => { + // Reset mocks + mockClient.session.list.mockClear() + mockClient.session.messages.mockClear() + mockClient.session.todo.mockClear() + }) + + test("getMainSessions uses SDK when beta mode is enabled", async () => { + // given + const mockSessions = [ + { id: "ses_1", directory: "/test", parentID: null, time: { created: 1000, updated: 2000 } }, + { id: "ses_2", directory: "/test", parentID: "ses_1", time: { created: 1000, updated: 1500 } }, + ] + mockClient.session.list.mockImplementation(() => Promise.resolve({ data: mockSessions })) + + // Mock isSqliteBackend to return true + mock.module("../../shared/opencode-storage-detection", () => ({ + isSqliteBackend: () => true, + resetSqliteBackendCache: () => {}, + })) + + // Re-import to get fresh module with mocked isSqliteBackend + const { setStorageClient, getMainSessions } = await import("./storage") + setStorageClient(mockClient as unknown as Parameters[0]) + + // when + const sessions = await getMainSessions({ directory: "/test" }) + + // then + expect(mockClient.session.list).toHaveBeenCalled() + expect(sessions.length).toBe(1) + expect(sessions[0].id).toBe("ses_1") + }) + + test("getAllSessions uses SDK when beta mode is enabled", async () => { + // given + const mockSessions = [ + { id: "ses_1", directory: "/test", time: { created: 1000, updated: 2000 } }, + { id: "ses_2", directory: "/test", time: { created: 1000, updated: 1500 } }, + ] + mockClient.session.list.mockImplementation(() => Promise.resolve({ data: mockSessions })) + + mock.module("../../shared/opencode-storage-detection", () => ({ + isSqliteBackend: () => true, + resetSqliteBackendCache: () => {}, + })) + + const { setStorageClient, getAllSessions } = await import("./storage") + setStorageClient(mockClient as unknown as Parameters[0]) + + // when + const sessionIDs = await getAllSessions() + + // then + expect(mockClient.session.list).toHaveBeenCalled() + expect(sessionIDs).toEqual(["ses_1", "ses_2"]) + }) + + test("readSessionMessages uses SDK when beta mode is enabled", async () => { + // given + const mockMessages = [ + { + info: { id: "msg_1", role: "user", agent: "test", time: { created: 1000 } }, + parts: [{ id: "part_1", type: "text", text: "Hello" }], + }, + { + info: { id: "msg_2", role: "assistant", agent: "oracle", time: { created: 2000 } }, + parts: [{ id: "part_2", type: "text", text: "Hi there" }], + }, + ] + mockClient.session.messages.mockImplementation(() => Promise.resolve({ data: mockMessages })) + + mock.module("../../shared/opencode-storage-detection", () => ({ + isSqliteBackend: () => true, + resetSqliteBackendCache: () => {}, + })) + + const { setStorageClient, readSessionMessages } = await import("./storage") + setStorageClient(mockClient as unknown as Parameters[0]) + + // when + const messages = await readSessionMessages("ses_test") + + // then + expect(mockClient.session.messages).toHaveBeenCalledWith({ path: { id: "ses_test" } }) + expect(messages.length).toBe(2) + expect(messages[0].id).toBe("msg_1") + expect(messages[1].id).toBe("msg_2") + expect(messages[0].role).toBe("user") + expect(messages[1].role).toBe("assistant") + }) + + test("readSessionTodos uses SDK when beta mode is enabled", async () => { + // given + const mockTodos = [ + { id: "todo_1", content: "Task 1", status: "pending", priority: "high" }, + { id: "todo_2", content: "Task 2", status: "completed", priority: "medium" }, + ] + mockClient.session.todo.mockImplementation(() => Promise.resolve({ data: mockTodos })) + + mock.module("../../shared/opencode-storage-detection", () => ({ + isSqliteBackend: () => true, + resetSqliteBackendCache: () => {}, + })) + + const { setStorageClient, readSessionTodos } = await import("./storage") + setStorageClient(mockClient as unknown as Parameters[0]) + + // when + const todos = await readSessionTodos("ses_test") + + // then + expect(mockClient.session.todo).toHaveBeenCalledWith({ path: { id: "ses_test" } }) + expect(todos.length).toBe(2) + expect(todos[0].content).toBe("Task 1") + expect(todos[1].content).toBe("Task 2") + expect(todos[0].status).toBe("pending") + expect(todos[1].status).toBe("completed") + }) + + test("SDK path returns empty array on error", async () => { + // given + mockClient.session.messages.mockImplementation(() => Promise.reject(new Error("API error"))) + + mock.module("../../shared/opencode-storage-detection", () => ({ + isSqliteBackend: () => true, + resetSqliteBackendCache: () => {}, + })) + + const { setStorageClient, readSessionMessages } = await import("./storage") + setStorageClient(mockClient as unknown as Parameters[0]) + + // when + const messages = await readSessionMessages("ses_test") + + // then + expect(messages).toEqual([]) + }) + + test("SDK path returns empty array when client is not set", async () => { + //#given beta mode enabled but no client set + mock.module("../../shared/opencode-storage-detection", () => ({ + isSqliteBackend: () => true, + resetSqliteBackendCache: () => {}, + })) + + //#when client is explicitly cleared and messages are requested + const { resetStorageClient, readSessionMessages } = await import("./storage") + resetStorageClient() + const messages = await readSessionMessages("ses_test") + + //#then should return empty array since no client and no JSON fallback + expect(messages).toEqual([]) + }) +}) diff --git a/src/tools/session-manager/storage.ts b/src/tools/session-manager/storage.ts index 8ed93f00..59fda3ff 100644 --- a/src/tools/session-manager/storage.ts +++ b/src/tools/session-manager/storage.ts @@ -1,14 +1,47 @@ -import { existsSync, readdirSync } from "node:fs" +import { existsSync } from "node:fs" import { readdir, readFile } from "node:fs/promises" import { join } from "node:path" +import type { PluginInput } from "@opencode-ai/plugin" import { MESSAGE_STORAGE, PART_STORAGE, SESSION_STORAGE, TODO_DIR, TRANSCRIPT_DIR } from "./constants" +import { isSqliteBackend } from "../../shared/opencode-storage-detection" +import { getMessageDir } from "../../shared/opencode-message-dir" import type { SessionMessage, SessionInfo, TodoItem, SessionMetadata } from "./types" +import { normalizeSDKResponse } from "../../shared" export interface GetMainSessionsOptions { directory?: string } +// SDK client reference for beta mode +let sdkClient: PluginInput["client"] | null = null + +export function setStorageClient(client: PluginInput["client"]): void { + sdkClient = client +} + +export function resetStorageClient(): void { + sdkClient = null +} + export async function getMainSessions(options: GetMainSessionsOptions): Promise { + // Beta mode: use SDK + if (isSqliteBackend() && sdkClient) { + try { + const response = await sdkClient.session.list() + const sessions = normalizeSDKResponse(response, [] as SessionMetadata[]) + const mainSessions = sessions.filter((s) => !s.parentID) + if (options.directory) { + return mainSessions + .filter((s) => s.directory === options.directory) + .sort((a, b) => b.time.updated - a.time.updated) + } + return mainSessions.sort((a, b) => b.time.updated - a.time.updated) + } catch { + return [] + } + } + + // Stable mode: use JSON files if (!existsSync(SESSION_STORAGE)) return [] const sessions: SessionMetadata[] = [] @@ -46,6 +79,18 @@ export async function getMainSessions(options: GetMainSessionsOptions): Promise< } export async function getAllSessions(): Promise { + // Beta mode: use SDK + if (isSqliteBackend() && sdkClient) { + try { + const response = await sdkClient.session.list() + const sessions = normalizeSDKResponse(response, [] as SessionMetadata[]) + return sessions.map((s) => s.id) + } catch { + return [] + } + } + + // Stable mode: use JSON files if (!existsSync(MESSAGE_STORAGE)) return [] const sessions: string[] = [] @@ -73,33 +118,78 @@ export async function getAllSessions(): Promise { return [...new Set(sessions)] } -export function getMessageDir(sessionID: string): string { - if (!existsSync(MESSAGE_STORAGE)) return "" +export { getMessageDir } from "../../shared/opencode-message-dir" - const directPath = join(MESSAGE_STORAGE, sessionID) - if (existsSync(directPath)) { - return directPath +export async function sessionExists(sessionID: string): Promise { + if (isSqliteBackend() && sdkClient) { + const response = await sdkClient.session.list() + const sessions = normalizeSDKResponse(response, [] as Array<{ id?: string }>) + return sessions.some((s) => s.id === sessionID) } - - try { - for (const dir of readdirSync(MESSAGE_STORAGE)) { - const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) - if (existsSync(sessionPath)) { - return sessionPath - } - } - } catch { - return "" - } - - return "" -} - -export function sessionExists(sessionID: string): boolean { - return getMessageDir(sessionID) !== "" + return getMessageDir(sessionID) !== null } export async function readSessionMessages(sessionID: string): Promise { + // Beta mode: use SDK + if (isSqliteBackend() && sdkClient) { + try { + const response = await sdkClient.session.messages({ path: { id: sessionID } }) + const rawMessages = normalizeSDKResponse(response, [] as Array<{ + info?: { + id?: string + role?: string + agent?: string + time?: { created?: number; updated?: number } + } + parts?: Array<{ + id?: string + type?: string + text?: string + thinking?: string + tool?: string + callID?: string + input?: Record + output?: string + error?: string + }> + }>) + const messages: SessionMessage[] = rawMessages + .filter((m) => m.info?.id) + .map((m) => ({ + id: m.info!.id!, + role: (m.info!.role as "user" | "assistant") || "user", + agent: m.info!.agent, + time: m.info!.time?.created + ? { + created: m.info!.time.created, + updated: m.info!.time.updated, + } + : undefined, + parts: + m.parts?.map((p) => ({ + id: p.id || "", + type: p.type || "text", + text: p.text, + thinking: p.thinking, + tool: p.tool, + callID: p.callID, + input: p.input, + output: p.output, + error: p.error, + })) || [], + })) + return messages.sort((a, b) => { + const aTime = a.time?.created ?? 0 + const bTime = b.time?.created ?? 0 + if (aTime !== bTime) return aTime - bTime + return a.id.localeCompare(b.id) + }) + } catch { + return [] + } + } + + // Stable mode: use JSON files const messageDir = getMessageDir(sessionID) if (!messageDir || !existsSync(messageDir)) return [] @@ -161,6 +251,28 @@ async function readParts(messageID: string): Promise { + // Beta mode: use SDK + if (isSqliteBackend() && sdkClient) { + try { + const response = await sdkClient.session.todo({ path: { id: sessionID } }) + const data = normalizeSDKResponse(response, [] as Array<{ + id?: string + content?: string + status?: string + priority?: string + }>) + return data.map((item) => ({ + id: item.id || "", + content: item.content || "", + status: (item.status as TodoItem["status"]) || "pending", + priority: item.priority, + })) + } catch { + return [] + } + } + + // Stable mode: use JSON files if (!existsSync(TODO_DIR)) return [] try { diff --git a/src/tools/session-manager/tools.ts b/src/tools/session-manager/tools.ts index 7650013c..e620c55b 100644 --- a/src/tools/session-manager/tools.ts +++ b/src/tools/session-manager/tools.ts @@ -6,7 +6,7 @@ import { SESSION_SEARCH_DESCRIPTION, SESSION_INFO_DESCRIPTION, } from "./constants" -import { getAllSessions, getMainSessions, getSessionInfo, readSessionMessages, readSessionTodos, sessionExists } from "./storage" +import { getAllSessions, getMainSessions, getSessionInfo, readSessionMessages, readSessionTodos, sessionExists, setStorageClient } from "./storage" import { filterSessionsByDate, formatSessionInfo, @@ -28,6 +28,9 @@ function withTimeout(promise: Promise, ms: number, operation: string): Pro } export function createSessionManagerTools(ctx: PluginInput): Record { + // Initialize storage client for SDK-based operations (beta mode) + setStorageClient(ctx.client) + const session_list: ToolDefinition = tool({ description: SESSION_LIST_DESCRIPTION, args: { @@ -67,12 +70,16 @@ export function createSessionManagerTools(ctx: PluginInput): Record { try { - if (!sessionExists(args.session_id)) { + if (!(await sessionExists(args.session_id))) { return `Session not found: ${args.session_id}` } let messages = await readSessionMessages(args.session_id) + if (messages.length === 0) { + return `Session not found: ${args.session_id}` + } + if (args.limit && args.limit > 0) { messages = messages.slice(0, args.limit) } diff --git a/src/tools/session-manager/types.ts b/src/tools/session-manager/types.ts index becaf13b..635b9a75 100644 --- a/src/tools/session-manager/types.ts +++ b/src/tools/session-manager/types.ts @@ -34,10 +34,10 @@ export interface SessionInfo { } export interface TodoItem { - id: string - content: string - status: "pending" | "in_progress" | "completed" | "cancelled" - priority?: string + id?: string; + content: string; + status: "pending" | "in_progress" | "completed" | "cancelled"; + priority?: string; } export interface SearchResult { diff --git a/src/tools/task/todo-sync.test.ts b/src/tools/task/todo-sync.test.ts index ed53f51d..e35d1978 100644 --- a/src/tools/task/todo-sync.test.ts +++ b/src/tools/task/todo-sync.test.ts @@ -418,12 +418,16 @@ describe("syncAllTasksToTodos", () => { }, ]; mockCtx.client.session.todo.mockResolvedValue(currentTodos); + let writtenTodos: TodoInfo[] = []; + const writer = async (input: { sessionID: string; todos: TodoInfo[] }) => { + writtenTodos = input.todos; + }; // when - await syncAllTasksToTodos(mockCtx, tasks, "session-1"); + await syncAllTasksToTodos(mockCtx, tasks, "session-1", writer); // then - expect(mockCtx.client.session.todo).toHaveBeenCalled(); + expect(writtenTodos.some((t: TodoInfo) => t.id === "T-1")).toBe(false); }); it("preserves existing todos not in task list", async () => { @@ -451,12 +455,17 @@ describe("syncAllTasksToTodos", () => { }, ]; mockCtx.client.session.todo.mockResolvedValue(currentTodos); + let writtenTodos: TodoInfo[] = []; + const writer = async (input: { sessionID: string; todos: TodoInfo[] }) => { + writtenTodos = input.todos; + }; // when - await syncAllTasksToTodos(mockCtx, tasks, "session-1"); + await syncAllTasksToTodos(mockCtx, tasks, "session-1", writer); // then - expect(mockCtx.client.session.todo).toHaveBeenCalled(); + expect(writtenTodos.some((t: TodoInfo) => t.id === "T-existing")).toBe(true); + expect(writtenTodos.some((t: TodoInfo) => t.content === "Task 1")).toBe(true); }); it("handles empty task list", async () => { @@ -471,7 +480,7 @@ describe("syncAllTasksToTodos", () => { expect(mockCtx.client.session.todo).toHaveBeenCalled(); }); - it("handles undefined sessionID", async () => { + it("calls writer with final todos", async () => { // given const tasks: Task[] = [ { @@ -484,13 +493,83 @@ describe("syncAllTasksToTodos", () => { }, ]; mockCtx.client.session.todo.mockResolvedValue([]); + let writerCalled = false; + const writer = async (input: { sessionID: string; todos: TodoInfo[] }) => { + writerCalled = true; + expect(input.sessionID).toBe("session-1"); + expect(input.todos.length).toBe(1); + expect(input.todos[0].content).toBe("Task 1"); + }; // when - await syncAllTasksToTodos(mockCtx, tasks); + await syncAllTasksToTodos(mockCtx, tasks, "session-1", writer); // then - expect(mockCtx.client.session.todo).toHaveBeenCalledWith({ - path: { id: "" }, - }); + expect(writerCalled).toBe(true); + }); + + it("deduplicates no-id todos when task replaces existing content", async () => { + // given + const tasks: Task[] = [ + { + id: "T-1", + subject: "Task 1 (updated)", + description: "Description 1", + status: "in_progress", + blocks: [], + blockedBy: [], + }, + ]; + const currentTodos: TodoInfo[] = [ + { + content: "Task 1 (updated)", + status: "pending", + }, + ]; + mockCtx.client.session.todo.mockResolvedValue(currentTodos); + let writtenTodos: TodoInfo[] = []; + const writer = async (input: { sessionID: string; todos: TodoInfo[] }) => { + writtenTodos = input.todos; + }; + + // when + await syncAllTasksToTodos(mockCtx, tasks, "session-1", writer); + + // then — no duplicates + const matching = writtenTodos.filter((t: TodoInfo) => t.content === "Task 1 (updated)"); + expect(matching.length).toBe(1); + expect(matching[0].status).toBe("in_progress"); + }); + + it("preserves todos without id field", async () => { + // given + const tasks: Task[] = [ + { + id: "T-1", + subject: "Task 1", + description: "Description 1", + status: "pending", + blocks: [], + blockedBy: [], + }, + ]; + const currentTodos: TodoInfo[] = [ + { + id: "T-1", + content: "Task 1", + status: "pending", + }, + { + content: "Todo without id", + status: "pending", + }, + ]; + mockCtx.client.session.todo.mockResolvedValue(currentTodos); + + // when + await syncAllTasksToTodos(mockCtx, tasks, "session-1"); + + // then + expect(mockCtx.client.session.todo).toHaveBeenCalled(); }); }); diff --git a/src/tools/task/todo-sync.ts b/src/tools/task/todo-sync.ts index 3243e723..c11849f8 100644 --- a/src/tools/task/todo-sync.ts +++ b/src/tools/task/todo-sync.ts @@ -3,7 +3,7 @@ import { log } from "../../shared/logger"; import type { Task } from "../../features/claude-tasks/types.ts"; export interface TodoInfo { - id: string; + id?: string; content: string; status: "pending" | "in_progress" | "completed" | "cancelled"; priority?: "low" | "medium" | "high"; @@ -47,6 +47,13 @@ function extractPriority( return undefined; } +function todosMatch(todo1: TodoInfo, todo2: TodoInfo): boolean { + if (todo1.id && todo2.id) { + return todo1.id === todo2.id; + } + return todo1.content === todo2.content; +} + export function syncTaskToTodo(task: Task): TodoInfo | null { const todoStatus = mapTaskStatusToTodoStatus(task.status); @@ -100,8 +107,18 @@ export async function syncTaskTodoUpdate( path: { id: sessionID }, }); const currentTodos = extractTodos(response); - const nextTodos = currentTodos.filter((todo) => todo.id !== task.id); - const todo = syncTaskToTodo(task); + const taskTodo = syncTaskToTodo(task); + const nextTodos = currentTodos.filter((todo) => { + if (taskTodo) { + return !todosMatch(todo, taskTodo); + } + // Deleted task: match by id if present, otherwise by content + if (todo.id) { + return todo.id !== task.id; + } + return todo.content !== task.subject; + }); + const todo = taskTodo; if (todo) { nextTodos.push(todo); @@ -122,6 +139,7 @@ export async function syncAllTasksToTodos( ctx: PluginInput, tasks: Task[], sessionID?: string, + writer?: TodoWriter, ): Promise { try { let currentTodos: TodoInfo[] = []; @@ -139,8 +157,10 @@ export async function syncAllTasksToTodos( const newTodos: TodoInfo[] = []; const tasksToRemove = new Set(); + const allTaskSubjects = new Set(); for (const task of tasks) { + allTaskSubjects.add(task.subject); const todo = syncTaskToTodo(task); if (todo === null) { tasksToRemove.add(task.id); @@ -150,16 +170,28 @@ export async function syncAllTasksToTodos( } const finalTodos: TodoInfo[] = []; - const newTodoIds = new Set(newTodos.map((t) => t.id)); + + const removedTaskSubjects = new Set( + tasks.filter((t) => t.status === "deleted").map((t) => t.subject), + ); for (const existing of currentTodos) { - if (!newTodoIds.has(existing.id) && !tasksToRemove.has(existing.id)) { + const isInNewTodos = newTodos.some((newTodo) => todosMatch(existing, newTodo)); + const isRemovedById = existing.id ? tasksToRemove.has(existing.id) : false; + const isRemovedByContent = !existing.id && removedTaskSubjects.has(existing.content); + const isReplacedByTask = !existing.id && allTaskSubjects.has(existing.content); + if (!isInNewTodos && !isRemovedById && !isRemovedByContent && !isReplacedByTask) { finalTodos.push(existing); } } finalTodos.push(...newTodos); + const resolvedWriter = writer ?? (await resolveTodoWriter()); + if (resolvedWriter && sessionID) { + await resolvedWriter({ sessionID, todos: finalTodos }); + } + log("[todo-sync] Synced todos", { count: finalTodos.length, sessionID,