Merge pull request #1837 from code-yeongyu/fuck-v1.2
feat: OpenCode beta SQLite migration compatibility
This commit is contained in:
72
AGENTS.md
72
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ describe("waitForEventProcessorShutdown", () => {
|
||||
const eventProcessor = new Promise<void>(() => {})
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/`)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -875,7 +875,7 @@ export class BackgroundManager {
|
||||
path: { id: sessionID },
|
||||
})
|
||||
|
||||
const messages = response.data ?? []
|
||||
const messages = ((response.data ?? response) as unknown as Array<{ info?: { role?: string } }>) ?? []
|
||||
|
||||
// Check for at least one assistant or tool message
|
||||
const hasAssistantOrToolMessage = messages.some(
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { getMessageDir } from "./message-storage-locator"
|
||||
export { getMessageDir } from "../../shared"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -4,7 +4,7 @@ function isTodo(value: unknown): value is Todo {
|
||||
if (typeof value !== "object" || value === null) return false
|
||||
const todo = value as Record<string, unknown>
|
||||
return (
|
||||
typeof todo["id"] === "string" &&
|
||||
(typeof todo["id"] === "string" || todo["id"] === undefined) &&
|
||||
typeof todo["content"] === "string" &&
|
||||
typeof todo["status"] === "string" &&
|
||||
typeof todo["priority"] === "string"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
export { injectHookMessage, findNearestMessageWithFields, findFirstMessageWithAgent } from "./injector"
|
||||
export {
|
||||
injectHookMessage,
|
||||
findNearestMessageWithFields,
|
||||
findFirstMessageWithAgent,
|
||||
findNearestMessageWithFieldsFromSDK,
|
||||
findFirstMessageWithAgentFromSDK,
|
||||
} from "./injector"
|
||||
export type { StoredMessage } from "./injector"
|
||||
export type { MessageMeta, OriginalMessageContext, TextPart, ToolPermission } from "./types"
|
||||
export { MESSAGE_STORAGE } from "./constants"
|
||||
|
||||
237
src/features/hook-message-injector/injector.test.ts
Normal file
237
src/features/hook-message-injector/injector.test.ts
Normal file
@@ -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<string, boolean>
|
||||
}
|
||||
}>): {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,10 @@
|
||||
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"
|
||||
|
||||
export interface StoredMessage {
|
||||
agent?: string
|
||||
@@ -10,14 +12,130 @@ export interface StoredMessage {
|
||||
tools?: Record<string, ToolPermission>
|
||||
}
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
interface SDKMessage {
|
||||
info?: {
|
||||
agent?: string
|
||||
model?: {
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
variant?: string
|
||||
}
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
tools?: Record<string, ToolPermission>
|
||||
}
|
||||
}
|
||||
|
||||
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<StoredMessage | null> {
|
||||
try {
|
||||
const response = await client.session.messages({ path: { id: sessionID } })
|
||||
const messages = ((response.data ?? response) as unknown as SDKMessage[]) ?? []
|
||||
|
||||
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<string | null> {
|
||||
try {
|
||||
const response = await client.session.messages({ path: { id: sessionID } })
|
||||
const messages = ((response.data ?? response) as unknown as SDKMessage[]) ?? []
|
||||
|
||||
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 +148,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 +167,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 +236,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 +268,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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
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<unknown>
|
||||
summarize: (opts: {
|
||||
path: { id: string }
|
||||
body: { providerID: string; modelID: string }
|
||||
query: { directory: string }
|
||||
}) => Promise<unknown>
|
||||
revert: (opts: {
|
||||
path: { id: string }
|
||||
body: { messageID: string; partID?: string }
|
||||
query: { directory: string }
|
||||
}) => Promise<unknown>
|
||||
prompt_async: (opts: {
|
||||
path: { id: string }
|
||||
body: { parts: Array<{ type: string; text: string }> }
|
||||
|
||||
@@ -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<string>(),
|
||||
@@ -43,6 +46,7 @@ export async function attemptDeduplicationRecovery(
|
||||
sessionID: string,
|
||||
parsed: ParsedTokenLimitError,
|
||||
experimental: ExperimentalConfig | undefined,
|
||||
client?: OpencodeClient,
|
||||
): Promise<void> {
|
||||
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) {
|
||||
|
||||
@@ -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<string, unknown>
|
||||
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<string[]> {
|
||||
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<string | null> {
|
||||
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 }
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,14 +1,120 @@
|
||||
import { log } from "../../shared/logger"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
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<string[]> {
|
||||
try {
|
||||
const response = (await client.session.messages({
|
||||
path: { id: sessionID },
|
||||
})) as { data?: SDKMessage[] }
|
||||
const messages = ((response.data ?? response) as unknown as SDKMessage[]) ?? []
|
||||
|
||||
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<number> {
|
||||
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
|
||||
|
||||
@@ -1,36 +1,39 @@
|
||||
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 { 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<string[]> {
|
||||
try {
|
||||
const response = await client.session.messages({ path: { id: sessionID } })
|
||||
const messages = ((response.data ?? response) as unknown as SDKMessage[]) ?? []
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
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"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
export interface DeduplicationConfig {
|
||||
enabled: boolean
|
||||
@@ -43,20 +47,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 +54,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 +69,29 @@ function readMessages(sessionID: string): MessagePart[] {
|
||||
return messages
|
||||
}
|
||||
|
||||
export function executeDeduplication(
|
||||
async function readMessagesFromSDK(client: OpencodeClient, sessionID: string): Promise<MessagePart[]> {
|
||||
try {
|
||||
const response = await client.session.messages({ path: { id: sessionID } })
|
||||
const rawMessages = ((response.data ?? response) as unknown as Array<{ parts?: ToolPart[] }>) ?? []
|
||||
return rawMessages.filter((m) => m.parts) as MessagePart[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeDeduplication(
|
||||
sessionID: string,
|
||||
state: PruningState,
|
||||
config: DeduplicationConfig,
|
||||
protectedTools: Set<string>
|
||||
): number {
|
||||
protectedTools: Set<string>,
|
||||
client?: OpencodeClient,
|
||||
): Promise<number> {
|
||||
if (!config.enabled) return 0
|
||||
|
||||
const messages = readMessages(sessionID)
|
||||
const messages = (client && isSqliteBackend())
|
||||
? await readMessagesFromSDK(client, sessionID)
|
||||
: readMessages(sessionID)
|
||||
|
||||
const signatures = new Map<string, ToolCallSignature[]>()
|
||||
|
||||
let currentTurn = 0
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
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"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
interface StoredToolPart {
|
||||
type?: string
|
||||
@@ -13,29 +19,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 +49,17 @@ function getMessageIds(sessionID: string): string[] {
|
||||
return messageIds
|
||||
}
|
||||
|
||||
export function truncateToolOutputsByCallId(
|
||||
export async function truncateToolOutputsByCallId(
|
||||
sessionID: string,
|
||||
callIds: Set<string>,
|
||||
): { 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 +100,42 @@ export function truncateToolOutputsByCallId(
|
||||
|
||||
return { truncatedCount }
|
||||
}
|
||||
|
||||
async function truncateToolOutputsByCallIdFromSDK(
|
||||
client: OpencodeClient,
|
||||
sessionID: string,
|
||||
callIds: Set<string>,
|
||||
): Promise<{ truncatedCount: number }> {
|
||||
try {
|
||||
const response = await client.session.messages({ path: { id: sessionID } })
|
||||
const messages = ((response.data ?? response) as unknown as SDKMessage[]) ?? []
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.]"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
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"
|
||||
|
||||
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 +34,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<AggressiveTruncateResult> {
|
||||
const { tokensToReduce, targetBytesToRemove } = calculateTargetBytesToRemove(
|
||||
currentTokens,
|
||||
maxTokens,
|
||||
@@ -38,6 +60,94 @@ export function truncateUntilTargetTokens(
|
||||
}
|
||||
}
|
||||
|
||||
if (client && isSqliteBackend()) {
|
||||
let toolPartsByKey = new Map<string, SDKToolPart>()
|
||||
try {
|
||||
const response = (await client.session.messages({
|
||||
path: { id: sessionID },
|
||||
})) as { data?: SDKMessage[] }
|
||||
const messages = (response.data ?? response) as SDKMessage[]
|
||||
toolPartsByKey = new Map<string, SDKToolPart>()
|
||||
|
||||
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<string, SDKToolPart>()
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
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"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
interface SDKToolPart {
|
||||
id: string
|
||||
type: string
|
||||
callID?: string
|
||||
tool?: string
|
||||
state?: {
|
||||
status?: string
|
||||
input?: Record<string, unknown>
|
||||
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<ToolResultInfo[]> {
|
||||
try {
|
||||
const response = await client.session.messages({ path: { id: sessionID } })
|
||||
const messages = ((response.data ?? response) as unknown as SDKMessage[]) ?? []
|
||||
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<string, unknown> = {
|
||||
...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<number> {
|
||||
try {
|
||||
const response = await client.session.messages({ path: { id: sessionID } })
|
||||
const messages = ((response.data ?? response) as unknown as SDKMessage[]) ?? []
|
||||
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<number> {
|
||||
const results = await findToolResultsBySizeFromSDK(client, sessionID)
|
||||
return results.reduce((sum, result) => sum + result.outputSize, 0)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ export function createAtlasEventHandler(input: {
|
||||
return
|
||||
}
|
||||
|
||||
const lastAgent = getLastAgentFromSession(sessionID)
|
||||
const lastAgent = await getLastAgentFromSession(sessionID, ctx.client)
|
||||
const requiredAgent = (boulderState.agent ?? "atlas").toLowerCase()
|
||||
const lastAgentMatchesRequired = lastAgent === requiredAgent
|
||||
const boulderAgentWasNotExplicitlySet = boulderState.agent === undefined
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } from "../../shared"
|
||||
import type { ModelInfo } from "./types"
|
||||
|
||||
export async function resolveRecentModelForSession(
|
||||
@@ -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
|
||||
|
||||
@@ -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<string | null> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export function createToolExecuteAfterHandler(input: {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isCallerOrchestrator(toolInput.sessionID)) {
|
||||
if (!(await isCallerOrchestrator(toolInput.sessionID, ctx.client))) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, string>
|
||||
}): (
|
||||
toolInput: { tool: string; sessionID?: string; callID?: string },
|
||||
toolOutput: { args: Record<string, unknown>; message?: string }
|
||||
) => Promise<void> {
|
||||
const { pendingFilePaths } = input
|
||||
const { ctx, pendingFilePaths } = input
|
||||
|
||||
return async (toolInput, toolOutput): Promise<void> => {
|
||||
if (!isCallerOrchestrator(toolInput.sessionID)) {
|
||||
if (!(await isCallerOrchestrator(toolInput.sessionID, ctx.client))) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string | undefined> {
|
||||
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<string | undefined> {
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export function createPrometheusMdOnlyHook(ctx: PluginInput) {
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { args: Record<string, unknown>; message?: string }
|
||||
): Promise<void> => {
|
||||
const agentName = getAgentFromSession(input.sessionID, ctx.directory)
|
||||
const agentName = await getAgentFromSession(input.sessionID, ctx.directory, ctx.client)
|
||||
|
||||
if (!isPrometheusAgent(agentName)) {
|
||||
return
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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"])
|
||||
|
||||
200
src/hooks/session-recovery/recover-empty-content-message-sdk.ts
Normal file
200
src/hooks/session-recovery/recover-empty-content-message-sdk.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
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"
|
||||
|
||||
type Client = ReturnType<typeof createOpencodeClient>
|
||||
|
||||
type ReplaceEmptyTextPartsAsync = (
|
||||
client: Client,
|
||||
sessionID: string,
|
||||
messageID: string,
|
||||
replacementText: string
|
||||
) => Promise<boolean>
|
||||
|
||||
type InjectTextPartAsync = (
|
||||
client: Client,
|
||||
sessionID: string,
|
||||
messageID: string,
|
||||
text: string
|
||||
) => Promise<boolean>
|
||||
|
||||
type FindMessagesWithEmptyTextPartsFromSDK = (
|
||||
client: Client,
|
||||
sessionID: string
|
||||
) => Promise<string[]>
|
||||
|
||||
export async function recoverEmptyContentMessageFromSDK(
|
||||
client: Client,
|
||||
sessionID: string,
|
||||
failedAssistantMsg: MessageData,
|
||||
error: unknown,
|
||||
dependencies: {
|
||||
placeholderText: string
|
||||
replaceEmptyTextPartsAsync: ReplaceEmptyTextPartsAsync
|
||||
injectTextPartAsync: InjectTextPartAsync
|
||||
findMessagesWithEmptyTextPartsFromSDK: FindMessagesWithEmptyTextPartsFromSDK
|
||||
}
|
||||
): Promise<boolean> {
|
||||
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<MessageData["parts"]>[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<MessageData[]> {
|
||||
try {
|
||||
const response = await client.session.messages({ path: { id: sessionID } })
|
||||
return ((response.data ?? response) as unknown as MessageData[]) ?? []
|
||||
} 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
|
||||
}
|
||||
@@ -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<typeof createOpencodeClient>
|
||||
|
||||
const PLACEHOLDER_TEXT = "[user interrupted]"
|
||||
|
||||
export async function recoverEmptyContentMessage(
|
||||
_client: Client,
|
||||
client: Client,
|
||||
sessionID: string,
|
||||
failedAssistantMsg: MessageData,
|
||||
_directory: string,
|
||||
error: unknown
|
||||
): Promise<boolean> {
|
||||
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
|
||||
|
||||
@@ -2,16 +2,23 @@ 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"
|
||||
|
||||
type Client = ReturnType<typeof createOpencodeClient>
|
||||
|
||||
export async function recoverThinkingBlockOrder(
|
||||
_client: Client,
|
||||
client: Client,
|
||||
sessionID: string,
|
||||
_failedAssistantMsg: MessageData,
|
||||
_directory: string,
|
||||
error: unknown
|
||||
): Promise<boolean> {
|
||||
if (isSqliteBackend()) {
|
||||
return recoverThinkingBlockOrderFromSDK(client, sessionID, error)
|
||||
}
|
||||
|
||||
const targetIndex = extractMessageIndex(error)
|
||||
if (targetIndex !== null) {
|
||||
const targetMessageID = findMessageByIndexNeedingThinking(sessionID, targetIndex)
|
||||
@@ -34,3 +41,96 @@ export async function recoverThinkingBlockOrder(
|
||||
|
||||
return anySuccess
|
||||
}
|
||||
|
||||
async function recoverThinkingBlockOrderFromSDK(
|
||||
client: Client,
|
||||
sessionID: string,
|
||||
error: unknown
|
||||
): Promise<boolean> {
|
||||
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<string[]> {
|
||||
let messages: MessageData[]
|
||||
try {
|
||||
const response = await client.session.messages({ path: { id: sessionID } })
|
||||
messages = ((response.data ?? response) as unknown as MessageData[]) ?? []
|
||||
} 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<string | null> {
|
||||
let messages: MessageData[]
|
||||
try {
|
||||
const response = await client.session.messages({ path: { id: sessionID } })
|
||||
messages = ((response.data ?? response) as unknown as MessageData[]) ?? []
|
||||
} 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
|
||||
}
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
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"
|
||||
|
||||
type Client = ReturnType<typeof createOpencodeClient>
|
||||
|
||||
export async function recoverThinkingDisabledViolation(
|
||||
_client: Client,
|
||||
client: Client,
|
||||
sessionID: string,
|
||||
_failedAssistantMsg: MessageData
|
||||
): Promise<boolean> {
|
||||
if (isSqliteBackend()) {
|
||||
return recoverThinkingDisabledViolationFromSDK(client, sessionID)
|
||||
}
|
||||
|
||||
const messagesWithThinking = findMessagesWithThinkingBlocks(sessionID)
|
||||
if (messagesWithThinking.length === 0) {
|
||||
return false
|
||||
@@ -23,3 +31,44 @@ export async function recoverThinkingDisabledViolation(
|
||||
|
||||
return anySuccess
|
||||
}
|
||||
|
||||
async function recoverThinkingDisabledViolationFromSDK(
|
||||
client: Client,
|
||||
sessionID: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const response = await client.session.messages({ path: { id: sessionID } })
|
||||
const messages = ((response.data ?? response) as unknown as MessageData[]) ?? []
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import type { MessageData } from "./types"
|
||||
import { readParts } from "./storage"
|
||||
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
|
||||
|
||||
type Client = ReturnType<typeof createOpencodeClient>
|
||||
|
||||
@@ -20,6 +21,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<MessagePart[]> {
|
||||
try {
|
||||
const response = await client.session.messages({ path: { id: sessionID } })
|
||||
const messages = ((response.data ?? response) as unknown as MessageData[]) ?? []
|
||||
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 +48,15 @@ export async function recoverToolResultMissing(
|
||||
): Promise<boolean> {
|
||||
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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
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"
|
||||
|
||||
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 +43,38 @@ export function replaceEmptyTextParts(messageID: string, replacementText: string
|
||||
return anyReplaced
|
||||
}
|
||||
|
||||
export async function replaceEmptyTextPartsAsync(
|
||||
client: OpencodeClient,
|
||||
sessionID: string,
|
||||
messageID: string,
|
||||
replacementText: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const response = await client.session.messages({ path: { id: sessionID } })
|
||||
const messages = ((response.data ?? response) as unknown as MessageData[]) ?? []
|
||||
|
||||
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 +94,24 @@ export function findMessagesWithEmptyTextParts(sessionID: string): string[] {
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export async function findMessagesWithEmptyTextPartsFromSDK(
|
||||
client: OpencodeClient,
|
||||
sessionID: string
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const response = await client.session.messages({ path: { id: sessionID } })
|
||||
const messages = ((response.data ?? response) as unknown as MessageData[]) ?? []
|
||||
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 []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 } 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,27 @@ export function readMessages(sessionID: string): StoredMessageMeta[] {
|
||||
return a.id.localeCompare(b.id)
|
||||
})
|
||||
}
|
||||
|
||||
export async function readMessagesFromSDK(
|
||||
client: OpencodeClient,
|
||||
sessionID: string
|
||||
): Promise<StoredMessageMeta[]> {
|
||||
try {
|
||||
const response = await client.session.messages({ path: { id: sessionID } })
|
||||
const data: unknown = response.data ?? response
|
||||
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 []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<StoredPart[]> {
|
||||
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 []
|
||||
}
|
||||
}
|
||||
|
||||
98
src/hooks/session-recovery/storage/readers-from-sdk.test.ts
Normal file
98
src/hooks/session-recovery/storage/readers-from-sdk.test.ts
Normal file
@@ -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<typeof readPartsFromSDK>[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<typeof readPartsFromSDK>[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<typeof readMessagesFromSDK>[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([])
|
||||
})
|
||||
})
|
||||
@@ -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<boolean> {
|
||||
const partId = generatePartId()
|
||||
const part: Record<string, unknown> = {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
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"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
function findLastThinkingContent(sessionID: string, beforeMessageID: string): string {
|
||||
const messages = readMessages(sessionID)
|
||||
@@ -31,6 +36,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 +49,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 +66,58 @@ export function prependThinkingPart(sessionID: string, messageID: string): boole
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function findLastThinkingContentFromSDK(
|
||||
client: OpencodeClient,
|
||||
sessionID: string,
|
||||
beforeMessageID: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
const response = await client.session.messages({ path: { id: sessionID } })
|
||||
const messages = ((response.data ?? response) as unknown as MessageData[]) ?? []
|
||||
|
||||
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<boolean> {
|
||||
const previousThinking = await findLastThinkingContentFromSDK(client, sessionID, messageID)
|
||||
|
||||
const partId = `prt_0000000000_${messageID}_thinking`
|
||||
const part: Record<string, unknown> = {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
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"
|
||||
|
||||
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 +34,33 @@ export function stripThinkingParts(messageID: string): boolean {
|
||||
|
||||
return anyRemoved
|
||||
}
|
||||
|
||||
export async function stripThinkingPartsAsync(
|
||||
client: OpencodeClient,
|
||||
sessionID: string,
|
||||
messageID: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const response = await client.session.messages({ path: { id: sessionID } })
|
||||
const messages = ((response.data ?? response) as unknown as Array<{ parts?: Array<{ type: string; id: string }> }>) ?? []
|
||||
|
||||
const targetMsg = messages.find((m) => {
|
||||
const info = (m as Record<string, unknown>)["info"] as Record<string, unknown> | 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import {
|
||||
findNearestMessageWithFields,
|
||||
findNearestMessageWithFieldsFromSDK,
|
||||
type ToolPermission,
|
||||
} from "../../features/hook-message-injector"
|
||||
import { log } from "../../shared/logger"
|
||||
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
|
||||
|
||||
import {
|
||||
CONTINUATION_PROMPT,
|
||||
@@ -78,8 +80,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 ??
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,10 @@ 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"
|
||||
|
||||
178
src/shared/opencode-http-api.test.ts
Normal file
178
src/shared/opencode-http-api.test.ts
Normal file
@@ -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",
|
||||
})
|
||||
})
|
||||
})
|
||||
140
src/shared/opencode-http-api.ts
Normal file
140
src/shared/opencode-http-api.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { getServerBasicAuthHeader } from "./opencode-server-auth"
|
||||
import { log } from "./logger"
|
||||
import { isRecord } from "./record-type-guard"
|
||||
|
||||
type UnknownRecord = Record<string, unknown>
|
||||
|
||||
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<string, unknown>
|
||||
): Promise<boolean> {
|
||||
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(30_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<boolean> {
|
||||
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(30_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
|
||||
}
|
||||
}
|
||||
107
src/shared/opencode-message-dir.test.ts
Normal file
107
src/shared/opencode-message-dir.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
31
src/shared/opencode-message-dir.ts
Normal file
31
src/shared/opencode-message-dir.ts
Normal file
@@ -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
|
||||
}
|
||||
94
src/shared/opencode-storage-detection.test.ts
Normal file
94
src/shared/opencode-storage-detection.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
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")
|
||||
let cachedResult: boolean | typeof NOT_CACHED = NOT_CACHED
|
||||
|
||||
function isSqliteBackend(): boolean {
|
||||
if (cachedResult !== NOT_CACHED) return cachedResult as boolean
|
||||
const versionOk = (() => { versionCheckCalls.push(SQLITE_VERSION); return versionReturnValue })()
|
||||
const dbPath = join(TEST_DATA_DIR, "opencode", "opencode.db")
|
||||
const dbExists = existsSync(dbPath)
|
||||
cachedResult = versionOk && dbExists
|
||||
return cachedResult
|
||||
}
|
||||
|
||||
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 the result and does not re-check on subsequent calls", () => {
|
||||
//#given
|
||||
versionReturnValue = true
|
||||
mkdirSync(join(TEST_DATA_DIR, "opencode"), { recursive: true })
|
||||
writeFileSync(DB_PATH, "")
|
||||
|
||||
//#when
|
||||
isSqliteBackend()
|
||||
isSqliteBackend()
|
||||
isSqliteBackend()
|
||||
|
||||
//#then
|
||||
expect(versionCheckCalls.length).toBe(1)
|
||||
})
|
||||
})
|
||||
24
src/shared/opencode-storage-detection.ts
Normal file
24
src/shared/opencode-storage-detection.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
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")
|
||||
let cachedResult: boolean | typeof NOT_CACHED = NOT_CACHED
|
||||
|
||||
export function isSqliteBackend(): boolean {
|
||||
if (cachedResult !== NOT_CACHED) {
|
||||
return cachedResult
|
||||
}
|
||||
|
||||
const versionOk = isOpenCodeVersionAtLeast(OPENCODE_SQLITE_VERSION)
|
||||
const dbPath = join(getDataDir(), "opencode", "opencode.db")
|
||||
const dbExists = existsSync(dbPath)
|
||||
|
||||
cachedResult = versionOk && dbExists
|
||||
return cachedResult
|
||||
}
|
||||
|
||||
export function resetSqliteBackendCache(): void {
|
||||
cachedResult = NOT_CACHED
|
||||
}
|
||||
7
src/shared/opencode-storage-paths.ts
Normal file
7
src/shared/opencode-storage-paths.ts
Normal file
@@ -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")
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,25 +1,22 @@
|
||||
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 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<boolean> {
|
||||
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 nearest?.agent?.toLowerCase() === "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)
|
||||
|
||||
@@ -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<string, unknown> | 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<string, unknown> | 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
|
||||
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
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 {
|
||||
findFirstMessageWithAgent,
|
||||
findFirstMessageWithAgentFromSDK,
|
||||
findNearestMessageWithFields,
|
||||
findNearestMessageWithFieldsFromSDK,
|
||||
} 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"
|
||||
import { delay } from "./delay"
|
||||
import { getMessageDir } from "./message-dir"
|
||||
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
|
||||
|
||||
type ToolContextWithMetadata = {
|
||||
sessionID: string
|
||||
@@ -18,7 +24,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 +44,17 @@ 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] = isSqliteBackend()
|
||||
? await Promise.all([
|
||||
findNearestMessageWithFieldsFromSDK(client, ctx.sessionID),
|
||||
findFirstMessageWithAgentFromSDK(client, ctx.sessionID),
|
||||
])
|
||||
: [
|
||||
messageDir ? findNearestMessageWithFields(messageDir) : null,
|
||||
messageDir ? findFirstMessageWithAgent(messageDir) : null,
|
||||
]
|
||||
|
||||
const sessionAgent = getSessionAgent(ctx.sessionID)
|
||||
const parentAgent = ctx.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
/// <reference types="bun-types" />
|
||||
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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,21 +1,38 @@
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import { findFirstMessageWithAgent, findNearestMessageWithFields } from "../../features/hook-message-injector"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import {
|
||||
findFirstMessageWithAgent,
|
||||
findFirstMessageWithAgentFromSDK,
|
||||
findNearestMessageWithFields,
|
||||
findNearestMessageWithFieldsFromSDK,
|
||||
} from "../../features/hook-message-injector"
|
||||
import { getSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { log } from "../../shared"
|
||||
import type { CallOmoAgentArgs } from "./types"
|
||||
import type { ToolContextWithMetadata } from "./tool-context-with-metadata"
|
||||
import { getMessageDir } from "./message-storage-directory"
|
||||
import { getSessionTools } from "../../shared/session-tools-store"
|
||||
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
|
||||
|
||||
export async function executeBackgroundAgent(
|
||||
args: CallOmoAgentArgs,
|
||||
toolContext: ToolContextWithMetadata,
|
||||
manager: BackgroundManager,
|
||||
client: PluginInput["client"],
|
||||
): Promise<string> {
|
||||
try {
|
||||
const messageDir = getMessageDir(toolContext.sessionID)
|
||||
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
const firstMessageAgent = messageDir ? findFirstMessageWithAgent(messageDir) : null
|
||||
|
||||
const [prevMessage, firstMessageAgent] = isSqliteBackend()
|
||||
? await Promise.all([
|
||||
findNearestMessageWithFieldsFromSDK(client, toolContext.sessionID),
|
||||
findFirstMessageWithAgentFromSDK(client, toolContext.sessionID),
|
||||
])
|
||||
: [
|
||||
messageDir ? findNearestMessageWithFields(messageDir) : null,
|
||||
messageDir ? findFirstMessageWithAgent(messageDir) : null,
|
||||
]
|
||||
|
||||
const sessionAgent = getSessionAgent(toolContext.sessionID)
|
||||
const parentAgent =
|
||||
toolContext.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
/// <reference types="bun-types" />
|
||||
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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
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 {
|
||||
findFirstMessageWithAgent,
|
||||
findFirstMessageWithAgentFromSDK,
|
||||
findNearestMessageWithFields,
|
||||
findNearestMessageWithFieldsFromSDK,
|
||||
} 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"
|
||||
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
|
||||
|
||||
export async function executeBackground(
|
||||
args: CallOmoAgentArgs,
|
||||
@@ -16,12 +23,22 @@ export async function executeBackground(
|
||||
abort: AbortSignal
|
||||
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void
|
||||
},
|
||||
manager: BackgroundManager
|
||||
manager: BackgroundManager,
|
||||
client: PluginInput["client"]
|
||||
): Promise<string> {
|
||||
try {
|
||||
const messageDir = getMessageDir(toolContext.sessionID)
|
||||
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
const firstMessageAgent = messageDir ? findFirstMessageWithAgent(messageDir) : null
|
||||
|
||||
const [prevMessage, firstMessageAgent] = isSqliteBackend()
|
||||
? await Promise.all([
|
||||
findNearestMessageWithFieldsFromSDK(client, toolContext.sessionID),
|
||||
findFirstMessageWithAgentFromSDK(client, toolContext.sessionID),
|
||||
])
|
||||
: [
|
||||
messageDir ? findNearestMessageWithFields(messageDir) : null,
|
||||
messageDir ? findFirstMessageWithAgent(messageDir) : null,
|
||||
]
|
||||
|
||||
const sessionAgent = getSessionAgent(toolContext.sessionID)
|
||||
const parentAgent = toolContext.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,14 +1,33 @@
|
||||
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 {
|
||||
findFirstMessageWithAgent,
|
||||
findFirstMessageWithAgentFromSDK,
|
||||
findNearestMessageWithFields,
|
||||
findNearestMessageWithFieldsFromSDK,
|
||||
} 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"
|
||||
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
|
||||
|
||||
export function resolveParentContext(ctx: ToolContextWithMetadata): ParentContext {
|
||||
export async function resolveParentContext(
|
||||
ctx: ToolContextWithMetadata,
|
||||
client: OpencodeClient
|
||||
): Promise<ParentContext> {
|
||||
const messageDir = getMessageDir(ctx.sessionID)
|
||||
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
const firstMessageAgent = messageDir ? findFirstMessageWithAgent(messageDir) : null
|
||||
|
||||
const [prevMessage, firstMessageAgent] = isSqliteBackend()
|
||||
? await Promise.all([
|
||||
findNearestMessageWithFieldsFromSDK(client, ctx.sessionID),
|
||||
findFirstMessageWithAgentFromSDK(client, ctx.sessionID),
|
||||
])
|
||||
: [
|
||||
messageDir ? findNearestMessageWithFields(messageDir) : null,
|
||||
messageDir ? findFirstMessageWithAgent(messageDir) : null,
|
||||
]
|
||||
|
||||
const sessionAgent = getSessionAgent(ctx.sessionID)
|
||||
const parentAgent = ctx.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user