Merge remote-tracking branch 'origin/dev' into fix/task-id-prompt-surface

# Conflicts:
#	src/agents/atlas/default-prompt-sections.ts
#	src/agents/atlas/gemini-prompt-sections.ts
#	src/agents/atlas/gpt-prompt-sections.ts
#	src/agents/hephaestus/gpt-5-3-codex.ts
This commit is contained in:
Disaster-Terminator
2026-05-17 10:17:24 +08:00
1312 changed files with 98022 additions and 12089 deletions
-12
View File
@@ -1,12 +0,0 @@
---
active: true
iteration: 2
max_iterations: 100
completion_promise: "DONE"
initial_completion_promise: "DONE"
started_at: "2026-03-14T04:20:58.486Z"
session_id: "new-session-1"
strategy: "reset"
message_count_at_start: 0
---
Build feature
+133 -162
View File
@@ -1,176 +1,147 @@
# src/hooks/ — 52 Lifecycle Hooks
# src/hooks/ — ~52 Lifecycle Hooks Across 58 Dirs
**Generated:** 2026-04-18
**Generated:** 2026-05-15
## OVERVIEW
52 hooks across dedicated modules and standalone files. Three-tier composition: Core(43) + Continuation(7) + Skill(2). All hooks follow `createXXXHook(deps) → HookFunction` factory pattern.
52 hooks (5 of the 58 dirs are `zauc-mocks-*` test scaffolds + 1 `shared/`). 5-tier composition wired in `src/plugin/hooks/`. All hooks follow `createXXXHook(deps) → HookFunction` factory pattern.
## HOOK TIERS
## TIER COMPOSITION
| Tier | Composer | Base | With team-mode | Where |
|------|----------|------|----------------|-------|
| **Session** | `create-session-hooks.ts` | 24 | 24 | OpenCode session lifecycle + chat.params + chat.message |
| **Tool Guard** | `create-tool-guard-hooks.ts` | 16 | 17 | Pre/post tool execution (+1: `team-tool-gating`) |
| **Transform** | `create-transform-hooks.ts` | 5 | 7 | `experimental.chat.messages.transform` (+2: `team-mode-status-injector`, `team-mailbox-injector`) |
| **Continuation** | `create-continuation-hooks.ts` | 7 | 7 | Boulder/atlas/compaction/notification |
| **Skill** | `create-skill-hooks.ts` | 2 | 2 | Skill awareness (categorySkillReminder, autoSlashCommand) |
| **Direct event handlers** | `src/plugin/event.ts` | 0 | +4 | `team-session-events/` sub-files: `team-idle-wake-hint`, `team-lead-orphan-handler`, `team-member-error-handler`, `team-member-status-handler` |
Total exposed hooks: **54 base, 61 with team-mode** (counts the 4 team-session-events handlers individually).
Hook name allowlist for `disabled_hooks`: all configurable hook names enumerated in [`src/config/schema/hooks.ts`](file:///Users/yeongyu/local-workspaces/omo/src/config/schema/hooks.ts) `HookNameSchema`. Team-session-event sub-hooks are not individually listed in the schema — they activate together with `team_mode.enabled`.
### Tier 1: Session Hooks (24)
| Hook | Event | Purpose |
|------|-------|---------|
| `contextWindowMonitor` | session.idle | Track context usage |
| `preemptiveCompaction` | session.idle | Trigger compaction before limit |
| `sessionRecovery` | session.error | Recover from structural errors (tool_result_missing, thinking_block_order) |
| `sessionNotification` | session.idle | OS notifications on completion |
| `thinkMode` | chat.params | Model variant switching for extended thinking |
| `anthropicContextWindowLimitRecovery` | session.error | Multi-strategy context recovery (truncation, compaction, dedup) |
| `autoUpdateChecker` | session.created | Check npm for plugin updates |
| `agentUsageReminder` | chat.message | Remind about available agents |
| `nonInteractiveEnv` | chat.message | Adjust behavior for `run` command |
| `interactiveBashSession` | tool.execute | Tmux session lifecycle for interactive_bash tool |
| `ralphLoop` | event | Self-referential dev loop (boulder continuation) |
| `editErrorRecovery` | tool.execute.after | Retry failed file edits |
| `delegateTaskRetry` | tool.execute.after | Retry failed task delegations |
| `startWork` | chat.message | `/start-work` command handler |
| `prometheusMdOnly` | tool.execute.before | Enforce .md-only writes for Prometheus |
| `sisyphusJuniorNotepad` | chat.message | Notepad injection for subagents |
| `questionLabelTruncator` | tool.execute.before | Truncate long Question tool labels |
| `taskResumeInfo` | chat.message | Inject task context on resume |
| `anthropicEffort` | chat.params | Adjust reasoning effort level |
| `modelFallback` | chat.params | Provider-level proactive model fallback |
| `noSisyphusGpt` | chat.message | Block Sisyphus from non-GPT providers (with warning toast) |
| `noHephaestusNonGpt` | chat.message | Block Hephaestus from non-GPT models |
| `runtimeFallback` | event | Reactive auto-switch on API provider errors |
| `legacyPluginToast` | chat.message | Show toast when legacy plugin name detected |
### Tier 2: Tool Guard Hooks (16)
| Hook | Event | Purpose |
|------|-------|---------|
| `commentChecker` | tool.execute.after | Block AI-slop comment patterns (binary: `@code-yeongyu/comment-checker`) |
| `toolOutputTruncator` | tool.execute.after | Truncate oversized tool output |
| `directoryAgentsInjector` | tool.execute.before | Inject dir-local AGENTS.md into context |
| `directoryReadmeInjector` | tool.execute.before | Inject dir-local README.md into context |
| `emptyTaskResponseDetector` | tool.execute.after | Detect empty task results |
| `rulesInjector` | tool.execute.before | Conditional rules injection (AGENTS.md, .rules) |
| `tasksTodowriteDisabler` | tool.execute.before | Disable TodoWrite when Sisyphus task system active |
| `writeExistingFileGuard` | tool.execute.before | Require Read before Write/Edit on existing files |
| `bashFileReadGuard` | tool.execute.before | Guard bash commands that read files (cat/head/tail) |
| `readImageResizer` | tool.execute.after | Resize large images for context efficiency |
| `todoDescriptionOverride` | tool.execute.before | Override todo item descriptions |
| `webfetchRedirectGuard` | tool.execute.before | Guard webfetch redirect behavior |
| `hashlineReadEnhancer` | tool.execute.after | Tag every Read output with `LINE#ID` content hashes |
| `jsonErrorRecovery` | tool.execute.after | Detect JSON parse errors, inject correction reminder |
| `fsyncSkipWarning` | tool.execute.after | Warn when fsync is skipped for atomic writes |
### Tier 3: Transform Hooks (5)
| Hook | Event | Purpose |
|------|-------|---------|
| `claudeCodeHooks` | messages.transform | Claude Code settings.json compatibility |
| `keywordDetector` | messages.transform | Detect ultrawork/search/analyze/team modes; inject mode-specific prompt |
| `contextInjectorMessagesTransform` | messages.transform | Inject AGENTS.md/README.md into context |
| `thinkingBlockValidator` | messages.transform | Validate thinking block structure |
| `toolPairValidator` | messages.transform | Validate tool call/result pairing |
### Tier 4: Continuation Hooks (7)
| Hook | Event | Purpose |
|------|-------|---------|
| `stopContinuationGuard` | chat.message | `/stop-continuation` command handler |
| `compactionContextInjector` | session.compacted | Re-inject context after compaction |
| `compactionTodoPreserver` | session.compacted | Preserve todos through compaction |
| `todoContinuationEnforcer` | session.idle | **Boulder** — force continuation on incomplete todos |
| `unstableAgentBabysitter` | session.idle | Monitor unstable agent behavior |
| `backgroundNotificationHook` | event | Background task completion notifications |
| `atlasHook` | event | Master orchestrator for boulder/background sessions |
### Tier 5: Skill Hooks (2)
| Hook | Event | Purpose |
|------|-------|---------|
| `categorySkillReminder` | chat.message | Hint to load skills before invoking categories |
| `autoSlashCommand` | chat.message | Auto-execute matching `/command` from user message |
### Team-mode Hooks (conditional, only when `team_mode.enabled: true`)
| Hook | Tier | Registered In | Purpose |
|------|------|---------------|---------|
| `team-mode-status-injector` | Transform | [`create-transform-hooks.ts`](file:///Users/yeongyu/local-workspaces/omo/src/plugin/hooks/create-transform-hooks.ts) | Inject `<team_mode_status>` block into messages |
| `team-mailbox-injector` | Transform | [`create-transform-hooks.ts`](file:///Users/yeongyu/local-workspaces/omo/src/plugin/hooks/create-transform-hooks.ts) | Pull pending team mailbox messages into agent context |
| `team-tool-gating` | Tool Guard | [`create-tool-guard-hooks.ts`](file:///Users/yeongyu/local-workspaces/omo/src/plugin/hooks/create-tool-guard-hooks.ts) | Restrict `team_*` tools based on member role + permissions |
| `team-idle-wake-hint` | event handler | [`src/plugin/event.ts`](file:///Users/yeongyu/local-workspaces/omo/src/plugin/event.ts) | Nudge idle team members back to work |
| `team-lead-orphan-handler` | event handler | [`src/plugin/event.ts`](file:///Users/yeongyu/local-workspaces/omo/src/plugin/event.ts) | Detect lead departure → orphan members |
| `team-member-error-handler` | event handler | [`src/plugin/event.ts`](file:///Users/yeongyu/local-workspaces/omo/src/plugin/event.ts) | React to member session errors |
| `team-member-status-handler` | event handler | [`src/plugin/event.ts`](file:///Users/yeongyu/local-workspaces/omo/src/plugin/event.ts) | Track member status transitions |
The 4 `team-session-events/` handlers live in `src/hooks/team-session-events/` (separate files: `team-idle-wake-hint.ts`, `team-lead-orphan-handler.ts`, `team-member-error-handler.ts`, `team-member-status-handler.ts`) and are wired into `src/plugin/event.ts` directly, not through a tier composer.
### Tier 1: Session Hooks (24) — `create-session-hooks.ts`
## STRUCTURE
```
hooks/
├── agent-usage-reminder/ # Reminds about available agents
├── atlas/ # Main orchestration (757 lines)
├── anthropic-context-window-limit-recovery/ # Auto-summarize
── anthropic-effort/ # Reasoning effort level adjustment
├── auto-slash-command/ # Detects /command patterns
├── auto-update-checker/ # Plugin update check
├── background-notification/ # OS notification
├── category-skill-reminder/ # Reminds of category skills
├── claude-code-hooks/ # settings.json compat layer
├── comment-checker/ # Prevents AI slop
├── compaction-context-injector/ # Injects context on compaction
├── compaction-todo-preserver/ # Preserves todos through compaction
├── delegate-task-retry/ # Retries failed delegations
├── directory-agents-injector/ # Auto-injects AGENTS.md
├── directory-readme-injector/ # Auto-injects README.md
├── edit-error-recovery/ # Recovers from failures
├── hashline-edit-diff-enhancer/ # Enhanced diff output for hashline edits
├── hashline-read-enhancer/ # Adds LINE#ID hashes to Read output
├── interactive-bash-session/ # Tmux session management
├── json-error-recovery/ # JSON parse error correction
├── keyword-detector/ # ultrawork/search/analyze modes
├── legacy-plugin-toast/ # Legacy plugin name migration toast
├── model-fallback/ # Provider-level model fallback
├── no-hephaestus-non-gpt/ # Block Hephaestus from non-GPT
├── no-sisyphus-gpt/ # Block Sisyphus from GPT
├── non-interactive-env/ # Non-TTY environment handling
├── prometheus-md-only/ # Planner read-only mode
├── question-label-truncator/ # Auto-truncates question labels
├── ralph-loop/ # Self-referential dev loop
├── read-image-resizer/ # Resize images for context efficiency
├── rules-injector/ # Conditional rules
├── runtime-fallback/ # Auto-switch models on API errors
├── session-recovery/ # Auto-recovers from crashes
├── sisyphus-junior-notepad/ # Sisyphus Junior notepad
├── start-work/ # Sisyphus work session starter
├── stop-continuation-guard/ # Guards stop continuation
├── task-reminder/ # Task system usage reminders
├── task-resume-info/ # Resume info for cancelled tasks
├── tasks-todowrite-disabler/ # Disable TodoWrite when task system active
├── think-mode/ # Dynamic thinking budget
├── thinking-block-validator/ # Ensures valid <thinking>
├── todo-continuation-enforcer/ # Force TODO completion
├── todo-description-override/ # Override todo descriptions
├── tool-pair-validator/ # Validate tool pair usage
├── unstable-agent-babysitter/ # Monitor unstable agent behavior
├── webfetch-redirect-guard/ # Guard webfetch redirect behavior
├── write-existing-file-guard/ # Require Read before Write
└── index.ts # Hook aggregation + registration
├── shared/ # Cross-hook helpers (timing, prompt builders, etc.)
├── (52 hook directories — see tier tables above)
├── zauc-mocks-{bg,cache,hook,ws}, zauc-sync-mocks # 5 test mocks (NOT hooks; named for sort-order isolation)
── (each hook dir)/
├── index.ts # createXXXHook factory + barrel
├── *.ts # implementation
└── *.test.ts # bun:test
```
| Hook | Event | Purpose |
|------|-------|---------|
| contextWindowMonitor | session.idle | Track context window usage |
| preemptiveCompaction | session.idle | Trigger compaction before limit |
| sessionRecovery | session.error | Auto-retry on recoverable errors |
| sessionNotification | session.idle | OS notifications on completion |
| thinkMode | chat.params | Model variant switching (extended thinking) |
| anthropicContextWindowLimitRecovery | session.error | Multi-strategy context recovery (truncation, compaction) |
| autoUpdateChecker | session.created | Check npm for plugin updates |
| agentUsageReminder | chat.message | Remind about available agents |
| nonInteractiveEnv | chat.message | Adjust behavior for `run` command |
| interactiveBashSession | tool.execute | Tmux session for interactive tools |
| ralphLoop | event | Self-referential dev loop (boulder continuation) |
| editErrorRecovery | tool.execute.after | Retry failed file edits |
| delegateTaskRetry | tool.execute.after | Retry failed task delegations |
| startWork | chat.message | `/start-work` command handler |
| prometheusMdOnly | tool.execute.before | Enforce .md-only writes for Prometheus |
| sisyphusJuniorNotepad | chat.message | Notepad injection for subagents |
| questionLabelTruncator | tool.execute.before | Truncate long question labels |
| taskResumeInfo | chat.message | Inject task context on resume |
| anthropicEffort | chat.params | Adjust reasoning effort level |
| modelFallback | chat.params | Provider-level model fallback on errors |
| noSisyphusGpt | chat.message | Block Sisyphus from using GPT models (toast warning) |
| noHephaestusNonGpt | chat.message | Block Hephaestus from using non-GPT models |
| runtimeFallback | event | Auto-switch models on API provider errors |
| legacyPluginToast | chat.message | Show toast when legacy plugin name detected |
## ADDING A NEW HOOK
### Tier 2: Tool Guard Hooks (14) — `create-tool-guard-hooks.ts`
1. `mkdir src/hooks/{name}` + `index.ts` exporting `createXXXHook(deps)`
2. Pick the right tier:
- Session lifecycle? → `create-session-hooks.ts`
- Pre/post tool? → `create-tool-guard-hooks.ts`
- Message transform? → `create-transform-hooks.ts`
- Continuation/idle? → `create-continuation-hooks.ts`
- Skill awareness? → `create-skill-hooks.ts`
- Team-mode-only? → register inside the team-mode conditional block
3. Add hook name to [`config/schema/hooks.ts`](file:///Users/yeongyu/local-workspaces/omo/src/config/schema/hooks.ts) `HookNameSchema`
4. Cover with co-located `*.test.ts` (given/when/then style)
| Hook | Event | Purpose |
|------|-------|---------|
| commentChecker | tool.execute.after | Block AI-generated comment patterns |
| toolOutputTruncator | tool.execute.after | Truncate oversized tool output |
| directoryAgentsInjector | tool.execute.before | Inject dir AGENTS.md into context |
| directoryReadmeInjector | tool.execute.before | Inject dir README.md into context |
| emptyTaskResponseDetector | tool.execute.after | Detect empty task responses |
| rulesInjector | tool.execute.before | Conditional rules injection (AGENTS.md, config) |
| tasksTodowriteDisabler | tool.execute.before | Disable TodoWrite when task system active |
| writeExistingFileGuard | tool.execute.before | Require Read before Write on existing files |
| bashFileReadGuard | tool.execute.before | Guard bash commands that read files |
| readImageResizer | tool.execute.after | Resize large images for context efficiency |
| todoDescriptionOverride | tool.execute.before | Override todo item descriptions |
| webfetchRedirectGuard | tool.execute.before | Guard webfetch redirect behavior |
| hashlineReadEnhancer | tool.execute.after | Enhance Read output with line hashes |
| jsonErrorRecovery | tool.execute.after | Detect JSON parse errors, inject correction reminder |
## NOTES
### Tier 3: Transform Hooks (5) — `create-transform-hooks.ts`
| Hook | Event | Purpose |
|------|-------|---------|
| claudeCodeHooks | messages.transform | Claude Code settings.json compatibility |
| keywordDetector | messages.transform | Detect ultrawork/search/analyze modes |
| contextInjectorMessagesTransform | messages.transform | Inject AGENTS.md/README.md into context |
| thinkingBlockValidator | messages.transform | Validate thinking block structure |
| toolPairValidator | messages.transform | Validate tool call/result pairs |
### Tier 4: Continuation Hooks (7) — `create-continuation-hooks.ts`
| Hook | Event | Purpose |
|------|-------|---------|
| stopContinuationGuard | chat.message | `/stop-continuation` command handler |
| compactionContextInjector | session.compacted | Re-inject context after compaction |
| compactionTodoPreserver | session.compacted | Preserve todos through compaction |
| todoContinuationEnforcer | session.idle | **Boulder**: force continuation on incomplete todos |
| unstableAgentBabysitter | session.idle | Monitor unstable agent behavior |
| backgroundNotificationHook | event | Background task completion notifications |
| atlasHook | event | Master orchestrator for boulder/background sessions |
### Tier 5: Skill Hooks (2) — `create-skill-hooks.ts`
| Hook | Event | Purpose |
|------|-------|---------|
| categorySkillReminder | chat.message | Remind about category+skill delegation |
| autoSlashCommand | chat.message | Auto-detect `/command` in user input |
## KEY HOOKS (COMPLEX)
### anthropic-context-window-limit-recovery (31 files, ~2232 LOC)
Multi-strategy recovery when hitting context limits. Strategies: truncation, compaction, summarization.
### atlas (17 files, ~1976 LOC)
Master orchestrator for boulder sessions. Decision gates: session type → abort check → failure count → background tasks → agent match → plan completeness → cooldown (5s). Injects continuation prompts on session.idle.
### ralph-loop (14 files, ~1687 LOC)
Self-referential dev loop via `/ralph-loop` command. State persisted in `.sisyphus/ralph-loop.local.md`. Detects `<promise>DONE</promise>` in AI output. Max 100 iterations default.
### todo-continuation-enforcer (13 files, ~2061 LOC)
"Boulder" mechanism. Forces agent to continue when todos remain incomplete. 2s countdown toast → continuation injection. Exponential backoff: 30s base, ×2 per failure, max 5 consecutive failures then 5min pause.
### keyword-detector (~1665 LOC)
Detects modes from user input: ultrawork, search, analyze, prove-yourself. Injects mode-specific system prompts.
### rules-injector (19 files, ~1604 LOC)
Conditional rules injection from AGENTS.md, config, skill rules. Evaluates conditions to determine which rules apply.
## STANDALONE HOOKS (in src/hooks/ root)
| File | Purpose |
|------|---------|
| context-window-monitor.ts | Track context window percentage |
| preemptive-compaction.ts | Trigger compaction before hard limit |
| tool-output-truncator.ts | Truncate tool output by token count |
| session-notification.ts + 4 helpers | OS notification on session completion |
| empty-task-response-detector.ts | Detect empty/failed task responses |
| session-todo-status.ts | Todo completion status tracking |
## HOW TO ADD A HOOK
1. Create `src/hooks/{name}/index.ts` with `createXXXHook(deps)` factory
2. Register in appropriate tier file (`src/plugin/hooks/create-{tier}-hooks.ts`)
3. Add hook name to `src/config/schema/hooks.ts` HookNameSchema
4. Hook receives `(event, ctx)` — return value depends on event type
- **Tier order matters within a phase:** within Session tier the registration order in `create-session-hooks.ts` determines invocation order — earlier hooks see un-mutated input, later hooks see accumulated output.
- **Mock files** (`zauc-mocks-*`, `zauc-sync-mocks`) are NOT hooks. They are placed inside `src/hooks/` purely so `bun:test` discovers them with the hook test fixtures.
- **`atlasHook` vs `todoContinuationEnforcer`:** atlas handles boulder/ralph/subagent sessions, todoContinuationEnforcer handles the main Sisyphus session. Both fire on `session.idle` but check session type first.
- **`runtime-fallback` vs `model-fallback`:** runtime-fallback is reactive (after error); model-fallback is proactive (chat.params). They operate independently.
+5 -10
View File
@@ -8,6 +8,7 @@ import { TARGET_TOOLS, AGENT_TOOLS, REMINDER_MESSAGE } from "./constants";
import type { AgentUsageState } from "./types";
import { getSessionAgent } from "../../features/claude-code-session-state";
import { getAgentConfigKey } from "../../shared/agent-display-names";
import { resolveSessionEventID } from "../../shared/event-session-id";
interface ToolExecuteInput {
tool: string;
@@ -41,6 +42,8 @@ const ORCHESTRATOR_AGENTS = new Set([
"prometheus",
]);
const MAX_REMINDERS = 3;
function isOrchestratorAgent(agentName: string): boolean {
return ORCHESTRATOR_AGENTS.has(getAgentConfigKey(agentName));
}
@@ -98,7 +101,7 @@ export function createAgentUsageReminderHook(_ctx: PluginInput) {
const state = getOrCreateState(sessionID);
if (state.agentUsed) {
if (state.agentUsed || state.reminderCount >= MAX_REMINDERS) {
return;
}
@@ -112,15 +115,7 @@ export function createAgentUsageReminderHook(_ctx: PluginInput) {
const props = event.properties as Record<string, unknown> | undefined;
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined;
if (sessionInfo?.id) {
resetState(sessionInfo.id);
}
}
if (event.type === "session.compacted") {
const sessionID = (props?.sessionID ??
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
const sessionID = resolveSessionEventID(props);
if (sessionID) {
resetState(sessionID);
}
@@ -0,0 +1,111 @@
import type { PluginInput } from "@opencode-ai/plugin";
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
import { createAgentUsageReminderHook } from "./index";
import { clearSessionAgent, updateSessionAgent, _resetForTesting } from "../../features/claude-code-session-state";
import { unsafeTestValue } from "../../../test-support/unsafe-test-value";
import * as storage from "./storage";
describe("agent-usage-reminder hook", () => {
let loadStateSpy: ReturnType<typeof spyOn>;
let saveStateSpy: ReturnType<typeof spyOn>;
let clearStateSpy: ReturnType<typeof spyOn>;
beforeEach(() => {
_resetForTesting();
loadStateSpy = spyOn(storage, "loadAgentUsageState").mockReturnValue(null);
saveStateSpy = spyOn(storage, "saveAgentUsageState").mockImplementation(mock(() => {}));
clearStateSpy = spyOn(storage, "clearAgentUsageState").mockImplementation(mock(() => {}));
});
afterEach(() => {
loadStateSpy?.mockRestore();
saveStateSpy?.mockRestore();
clearStateSpy?.mockRestore();
});
function createHook() {
return createAgentUsageReminderHook(unsafeTestValue<PluginInput>({}));
}
test("caps reminders and does not re-arm after session.compacted", async () => {
// given - an orchestrator session has already hit the reminder cap
const hook = createHook();
const sessionID = "agent-usage-compact-session";
updateSessionAgent(sessionID, "Sisyphus");
const output1 = { title: "", output: "result-1", metadata: {} };
const output2 = { title: "", output: "result-2", metadata: {} };
const output3 = { title: "", output: "result-3", metadata: {} };
const output4 = { title: "", output: "result-4", metadata: {} };
await hook["tool.execute.after"]({ tool: "grep", sessionID, callID: "1" }, output1);
await hook["tool.execute.after"]({ tool: "grep", sessionID, callID: "2" }, output2);
await hook["tool.execute.after"]({ tool: "grep", sessionID, callID: "3" }, output3);
// then - the first three reminders are shown
expect(output1.output).toContain("[Agent Usage Reminder]");
expect(output2.output).toContain("[Agent Usage Reminder]");
expect(output3.output).toContain("[Agent Usage Reminder]");
// when - compaction happens and another target tool runs
await hook.event({ event: { type: "session.compacted", properties: { sessionID } } });
await hook["tool.execute.after"]({ tool: "grep", sessionID, callID: "4" }, output4);
// then - compaction does not reset the reminder cap
expect(output4.output).not.toContain("[Agent Usage Reminder]");
clearSessionAgent(sessionID);
});
test("resets reminder state on session.deleted", async () => {
// given - an orchestrator session has reminder state
const hook = createHook();
const sessionID = "agent-usage-delete-session";
updateSessionAgent(sessionID, "Sisyphus");
const output1 = { title: "", output: "result-1", metadata: {} };
const output2 = { title: "", output: "result-2", metadata: {} };
const output3 = { title: "", output: "result-3", metadata: {} };
const output4 = { title: "", output: "result-4", metadata: {} };
const output5 = { title: "", output: "result-5", metadata: {} };
await hook["tool.execute.after"]({ tool: "grep", sessionID, callID: "1" }, output1);
await hook["tool.execute.after"]({ tool: "grep", sessionID, callID: "2" }, output2);
await hook["tool.execute.after"]({ tool: "grep", sessionID, callID: "3" }, output3);
await hook["tool.execute.after"]({ tool: "grep", sessionID, callID: "4" }, output4);
expect(output1.output).toContain("[Agent Usage Reminder]");
expect(output2.output).toContain("[Agent Usage Reminder]");
expect(output3.output).toContain("[Agent Usage Reminder]");
expect(output4.output).not.toContain("[Agent Usage Reminder]");
// when - the session is deleted and another target tool runs
await hook.event({ event: { type: "session.deleted", properties: { info: { id: sessionID } } } });
await hook["tool.execute.after"]({ tool: "grep", sessionID, callID: "5" }, output5);
// then - deletion still resets the state
expect(output5.output).toContain("[Agent Usage Reminder]");
clearSessionAgent(sessionID);
});
test("does not re-arm after session.compacted when task delegation already happened", async () => {
// given - an orchestrator session already delegated through task
const hook = createHook();
const sessionID = "agent-usage-delegated-session";
updateSessionAgent(sessionID, "Sisyphus");
const output = { title: "", output: "result", metadata: {} };
await hook["tool.execute.after"]({ tool: "task", sessionID, callID: "1" }, output);
// when - compaction happens and another target tool runs
await hook.event({ event: { type: "session.compacted", properties: { sessionID } } });
await hook["tool.execute.after"]({ tool: "grep", sessionID, callID: "2" }, output);
// then - compaction does not clear delegated state
expect(output.output).not.toContain("[Agent Usage Reminder]");
clearSessionAgent(sessionID);
});
});
@@ -1,6 +1,6 @@
# src/hooks/anthropic-context-window-limit-recovery/ — Multi-Strategy Context Recovery
**Generated:** 2026-04-11
**Generated:** 2026-05-15
## OVERVIEW
@@ -0,0 +1,203 @@
/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import type { AutoCompactState } from "./types"
type PromptAsyncCall = {
path: { id: string }
body: {
auto?: boolean
agent?: string
model?: { providerID: string; modelID: string }
variant?: string
tools?: Record<string, boolean>
parts?: unknown
}
query: { directory: string }
}
const truncateUntilTargetTokensMock = mock(async () => ({
truncatedCount: 1,
totalBytesRemoved: 1000,
truncatedTools: [{ toolName: "bash" }],
sufficient: true,
}))
mock.module("./storage", () => ({
truncateUntilTargetTokens: truncateUntilTargetTokensMock,
}))
const findNearestMessageWithFieldsFromSDKMock = mock(async () => null)
const findNearestMessageWithFieldsMock = mock(() => null)
mock.module("../../features/hook-message-injector", () => ({
findNearestMessageWithFieldsFromSDK: findNearestMessageWithFieldsFromSDKMock,
findNearestMessageWithFields: findNearestMessageWithFieldsMock,
}))
import { _resetForTesting as resetSessionState, updateSessionAgent } from "../../features/claude-code-session-state/state"
import { runAggressiveTruncationStrategy } from "./aggressive-truncation-strategy"
type FakeClient = {
session: {
promptAsync: (input: PromptAsyncCall) => Promise<unknown>
status?: () => Promise<unknown>
}
tui: { showToast: (input: unknown) => Promise<unknown> }
}
function createRecordingClient(status?: () => Promise<unknown>): { client: FakeClient; calls: PromptAsyncCall[] } {
const calls: PromptAsyncCall[] = []
const client: FakeClient = {
session: {
promptAsync: async (input: PromptAsyncCall) => {
calls.push(input)
return undefined
},
...(status ? { status } : {}),
},
tui: {
showToast: async () => undefined,
},
}
return { client, calls }
}
function createAutoCompactState(): AutoCompactState {
return {
pendingCompact: new Set<string>(),
errorDataBySession: new Map(),
retryStateBySession: new Map(),
retryTimerBySession: new Map(),
truncateStateBySession: new Map(),
emptyContentAttemptBySession: new Map(),
compactionInProgress: new Set<string>(),
}
}
async function flushDeferredPrompt(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 600))
}
describe("runAggressiveTruncationStrategy - pins agent/model/variant on recovered promptAsync", () => {
beforeEach(() => {
resetSessionState()
truncateUntilTargetTokensMock.mockClear()
findNearestMessageWithFieldsFromSDKMock.mockClear()
findNearestMessageWithFieldsMock.mockClear()
findNearestMessageWithFieldsFromSDKMock.mockResolvedValue(null)
findNearestMessageWithFieldsMock.mockReturnValue(null)
})
afterEach(() => {
resetSessionState()
})
test("includes the session's resolved agent on promptAsync when agent is known", async () => {
// given
const { client, calls } = createRecordingClient()
const sessionID = "session-truncation-agent"
updateSessionAgent(sessionID, "sisyphus-junior")
// when
await runAggressiveTruncationStrategy({
sessionID,
autoCompactState: createAutoCompactState(),
client: client as never,
directory: "/tmp/test-truncation",
truncateAttempt: 0,
currentTokens: 250_000,
maxTokens: 200_000,
})
await flushDeferredPrompt()
// then
expect(calls).toHaveLength(1)
expect(calls[0].path.id).toBe(sessionID)
expect(calls[0].body.agent).toBe("sisyphus-junior")
expect(calls[0].body.auto).toBe(true)
})
test("pins provider/model/variant resolved from the nearest prior assistant message", async () => {
// given
const { client, calls } = createRecordingClient()
const sessionID = "session-truncation-model"
findNearestMessageWithFieldsFromSDKMock.mockResolvedValue({
agent: "atlas",
model: { providerID: "anthropic", modelID: "claude-opus-4-7", variant: "high" },
tools: undefined,
} as never)
findNearestMessageWithFieldsMock.mockReturnValue({
agent: "atlas",
model: { providerID: "anthropic", modelID: "claude-opus-4-7", variant: "high" },
tools: undefined,
} as never)
// when
await runAggressiveTruncationStrategy({
sessionID,
autoCompactState: createAutoCompactState(),
client: client as never,
directory: "/tmp/test-truncation",
truncateAttempt: 0,
currentTokens: 250_000,
maxTokens: 200_000,
})
await flushDeferredPrompt()
// then
expect(calls).toHaveLength(1)
expect(calls[0].body.agent).toBe("atlas")
expect(calls[0].body.model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-7" })
expect(calls[0].body.variant).toBe("high")
expect(calls[0].body.auto).toBe(true)
})
test("omits agent/model/variant when the session has nothing resolvable", async () => {
// given
const { client, calls } = createRecordingClient()
const sessionID = "session-truncation-empty"
// when
await runAggressiveTruncationStrategy({
sessionID,
autoCompactState: createAutoCompactState(),
client: client as never,
directory: "/tmp/test-truncation",
truncateAttempt: 0,
currentTokens: 250_000,
maxTokens: 200_000,
})
await flushDeferredPrompt()
// then
expect(calls).toHaveLength(1)
expect(calls[0].body.agent).toBeUndefined()
expect(calls[0].body.model).toBeUndefined()
expect(calls[0].body.variant).toBeUndefined()
expect(calls[0].body.auto).toBe(true)
})
test("does not send the delayed auto prompt when the session becomes active before recovery fires", async () => {
// given
const sessionID = "session-truncation-active"
const { client, calls } = createRecordingClient(async () => ({
[sessionID]: { type: "busy" },
}))
// when
await runAggressiveTruncationStrategy({
sessionID,
autoCompactState: createAutoCompactState(),
client: client as never,
directory: "/tmp/test-truncation",
truncateAttempt: 0,
currentTokens: 250_000,
maxTokens: 200_000,
})
await flushDeferredPrompt()
// then
expect(calls).toHaveLength(0)
})
})
@@ -5,7 +5,19 @@ import type { Client } from "./client"
import { clearSessionState } from "./state"
import { formatBytes } from "./message-builder"
import { log } from "../../shared/logger"
import { resolveInheritedPromptTools } from "../../shared"
import {
getMessageDir,
resolveInheritedPromptTools,
} from "../../shared"
import {
getSessionAgent,
resolveRegisteredAgentName,
} from "../../features/claude-code-session-state/state"
import {
findNearestMessageWithFields,
findNearestMessageWithFieldsFromSDK,
} from "../../features/hook-message-injector"
import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate"
export async function runAggressiveTruncationStrategy(params: {
sessionID: string
@@ -62,16 +74,49 @@ export async function runAggressiveTruncationStrategy(params: {
clearSessionState(params.autoCompactState, params.sessionID)
setTimeout(async () => {
try {
const inheritedTools = resolveInheritedPromptTools(params.sessionID)
await params.client.session.promptAsync({
path: { id: params.sessionID },
body: {
auto: true,
...(inheritedTools ? { tools: inheritedTools } : {}),
const sdkMessage = await findNearestMessageWithFieldsFromSDK(params.client, params.sessionID)
const previousMessage = sdkMessage ?? (() => {
const messageDir = getMessageDir(params.sessionID)
return messageDir ? findNearestMessageWithFields(messageDir) : null
})()
const agentName = getSessionAgent(params.sessionID) ?? previousMessage?.agent
const launchAgent = resolveRegisteredAgentName(agentName)
const launchModel = previousMessage?.model?.providerID && previousMessage.model.modelID
? { providerID: previousMessage.model.providerID, modelID: previousMessage.model.modelID }
: undefined
const launchVariant = previousMessage?.model?.variant
const inheritedTools = resolveInheritedPromptTools(params.sessionID, previousMessage?.tools)
const promptResult = await promptAsyncAfterSessionIdle({
client: params.client,
sessionID: params.sessionID,
source: "auto-compact",
settleMs: 0,
input: {
path: { id: params.sessionID },
body: {
auto: true,
...(launchAgent ? { agent: launchAgent } : {}),
...(launchModel ? { model: launchModel } : {}),
...(launchVariant ? { variant: launchVariant } : {}),
...(inheritedTools ? { tools: inheritedTools } : {}),
} as never,
query: { directory: params.directory },
} as never,
query: { directory: params.directory },
})
} catch {}
if (promptResult.status !== "dispatched") {
log("[auto-compact] delayed auto prompt skipped by promptAsync gate", {
sessionID: params.sessionID,
status: promptResult.status,
})
}
} catch (error) {
log("[auto-compact] delayed auto prompt failed", {
sessionID: params.sessionID,
error: String(error),
})
}
}, 500)
return { handled: true, nextTruncateAttempt }
@@ -5,6 +5,7 @@ import { executeCompact } from "./executor"
import type { AutoCompactState } from "./types"
import * as recoveryStrategy from "./recovery-strategy"
import * as messagesReader from "../session-recovery/storage/messages-reader"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
type TimerCallback = (...args: any[]) => void
@@ -37,7 +38,7 @@ function createFakeTimeouts(): FakeTimeouts {
callback,
args,
})
return id as unknown as ReturnType<typeof setTimeout>
return unsafeTestValue<ReturnType<typeof setTimeout>>(id)
}) as typeof setTimeout
globalThis.clearTimeout = ((id?: number) => {
@@ -243,7 +244,7 @@ describe("executeCompact lock management", () => {
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory, pluginConfig)
// then: Toast should be shown
const toastCalls = (mockClient.tui.showToast as any).mock.calls
const toastCalls = (unsafeTestValue(mockClient.tui.showToast)).mock.calls
const blockedToast = toastCalls.find(
(call: any) => call[0]?.body?.title === "Compact In Progress",
)
@@ -276,7 +277,7 @@ describe("executeCompact lock management", () => {
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory, pluginConfig)
// then: Should show failure toast
const toastCalls = (mockClient.tui.showToast as any).mock.calls
const toastCalls = (unsafeTestValue(mockClient.tui.showToast)).mock.calls
const failureToast = toastCalls.find(
(call: any) => call[0]?.body?.title === "Auto Compact Failed",
)
@@ -2,6 +2,7 @@ import { describe, test, expect, mock, beforeEach, afterAll } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import type { ExperimentalConfig } from "../../config"
import * as originalDeduplicationRecovery from "./deduplication-recovery"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
const attemptDeduplicationRecoveryMock = mock(async () => {})
@@ -20,7 +21,7 @@ function createImmediateTimeouts(): () => void {
globalThis.setTimeout = ((callback: (...args: unknown[]) => void, _delay?: number, ...args: unknown[]) => {
callback(...args)
return 0 as unknown as ReturnType<typeof setTimeout>
return unsafeTestValue<ReturnType<typeof setTimeout>>(0)
}) as typeof setTimeout
globalThis.clearTimeout = ((_: ReturnType<typeof setTimeout>) => {}) as typeof clearTimeout
@@ -7,6 +7,7 @@ import { executeCompact, getLastAssistant } from "./executor"
import { attemptDeduplicationRecovery } from "./deduplication-recovery"
import { clearSessionState } from "./state"
import { clearAllSessionTimeouts, clearSessionTimeout } from "./session-timeout-map"
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
import { log } from "../../shared/logger"
export interface AnthropicContextWindowLimitRecoveryOptions {
@@ -53,17 +54,17 @@ export function createAnthropicContextWindowLimitRecoveryHook(
const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id) {
clearSessionTimeout(pendingCompactionTimeoutBySession, sessionInfo.id)
const sessionID = resolveSessionEventID(props)
if (sessionID) {
clearSessionTimeout(pendingCompactionTimeoutBySession, sessionID)
clearSessionState(autoCompactState, sessionInfo.id)
clearSessionState(autoCompactState, sessionID)
}
return
}
if (event.type === "session.error") {
const sessionID = props?.sessionID as string | undefined
const sessionID = resolveSessionEventID(props)
dependencies.log("[auto-compact] session.error received", { sessionID, error: props?.error })
if (!sessionID) return
@@ -120,7 +121,7 @@ export function createAnthropicContextWindowLimitRecoveryHook(
if (event.type === "message.updated") {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined
const sessionID = resolveMessageEventSessionID(props)
if (sessionID && info?.role === "assistant" && info.error) {
dependencies.log("[auto-compact] message.updated with error", { sessionID, error: info.error })
@@ -137,7 +138,7 @@ export function createAnthropicContextWindowLimitRecoveryHook(
}
if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined
const sessionID = resolveSessionEventID(props)
if (!sessionID) return
if (!autoCompactState.pendingCompact.has(sessionID)) return
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import { runSummarizeRetryStrategy } from "./summarize-retry-strategy"
import type { AutoCompactState, ParsedTokenLimitError, RetryState } from "./types"
import type { OhMyOpenCodeConfig } from "../../config"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
type TimeoutCall = {
handle: ReturnType<typeof setTimeout>
@@ -95,7 +96,7 @@ describe("runSummarizeRetryStrategy", () => {
//#given
const timeoutCalls: TimeoutCall[] = []
globalThis.setTimeout = ((_: (...args: unknown[]) => void, delay?: number) => {
const handle = timeoutCalls.length + 1 as unknown as ReturnType<typeof setTimeout>
const handle = unsafeTestValue<ReturnType<typeof setTimeout>>(timeoutCalls.length + 1)
timeoutCalls.push({ handle, delay: delay ?? 0 })
return handle
}) as typeof setTimeout
@@ -132,7 +133,7 @@ describe("runSummarizeRetryStrategy", () => {
let scheduledCallback: (() => void) | undefined
globalThis.setTimeout = ((callback: (...args: unknown[]) => void, _delay?: number) => {
scheduledCallback = () => callback()
return 1 as unknown as ReturnType<typeof setTimeout>
return unsafeTestValue<ReturnType<typeof setTimeout>>(1)
}) as typeof setTimeout
autoCompactState.pendingCompact.add(sessionID)
@@ -176,7 +177,7 @@ describe("runSummarizeRetryStrategy", () => {
autoCompactState.emptyContentAttemptBySession.set(sessionID, 3)
autoCompactState.retryTimerBySession.set(
sessionID,
1 as unknown as ReturnType<typeof setTimeout>,
unsafeTestValue<ReturnType<typeof setTimeout>>(1),
)
//#when
+2 -2
View File
@@ -1,6 +1,6 @@
# src/hooks/atlas/ — Master Boulder Orchestrator
**Generated:** 2026-04-18
**Generated:** 2026-05-15
## OVERVIEW
@@ -42,7 +42,7 @@ session.idle event
| `session-last-agent.ts` | Determine which agent owns the session |
| `recent-model-resolver.ts` | Resolve model used in recent messages |
| `subagent-session-id.ts` | Detect if session is a subagent session |
| `sisyphus-path.ts` | Resolve `.sisyphus/` directory path |
| `omo-path.ts` | Resolve `.omo/` directory path |
| `is-abort-error.ts` | Detect abort signals in session output |
| `types.ts` | `SessionState`, `AtlasHookOptions`, `AtlasContext` |
+17 -2
View File
@@ -8,6 +8,7 @@ export function createAtlasHook(ctx: PluginInput, options?: AtlasHookOptions) {
const sessions = new Map<string, SessionState>()
const pendingFilePaths = new Map<string, string>()
const pendingTaskRefs = new Map<string, PendingTaskRef>()
const pendingPlanSnapshots = new Map<string, string>()
const autoCommit = options?.autoCommit ?? true
function getState(sessionID: string): SessionState {
@@ -21,7 +22,21 @@ export function createAtlasHook(ctx: PluginInput, options?: AtlasHookOptions) {
return {
handler: createAtlasEventHandler({ ctx, options, sessions, getState }),
"tool.execute.before": createToolExecuteBeforeHandler({ ctx, pendingFilePaths, pendingTaskRefs }),
"tool.execute.after": createToolExecuteAfterHandler({ ctx, pendingFilePaths, pendingTaskRefs, autoCommit, getState }),
"tool.execute.before": createToolExecuteBeforeHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
pendingPlanSnapshots,
isCallerOrchestrator: options?.isCallerOrchestrator,
}),
"tool.execute.after": createToolExecuteAfterHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
pendingPlanSnapshots,
autoCommit,
getState,
isCallerOrchestrator: options?.isCallerOrchestrator,
}),
}
}
@@ -1,5 +1,14 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { appendSessionId, type BoulderState, upsertTaskSessionState } from "../../features/boulder-state"
import {
appendSessionId,
appendSessionIdForWork,
getWorkForSession,
type BoulderState,
resolveBoulderPlanPath,
resolveBoulderPlanPathForWork,
upsertTaskSessionState,
upsertTaskSessionStateForWork,
} from "../../features/boulder-state"
import { log } from "../../shared/logger"
import { HOOK_NAME } from "./hook-name"
import { extractSessionIdFromOutput, validateSubagentSessionId } from "./subagent-session-id"
@@ -19,8 +28,13 @@ export async function syncBackgroundLaunchSessionTracking(input: {
return
}
if (typeof toolInput.sessionID !== "string") {
return
}
const trackedWork = getWorkForSession(ctx.directory, toolInput.sessionID)
const extractedSessionId = metadataSessionId ?? extractSessionIdFromOutput(toolOutput.output)
const lineageSessionIDs = boulderState.session_ids
const lineageSessionIDs = trackedWork?.session_ids ?? boulderState.session_ids
const subagentSessionId = await validateSubagentSessionId({
client: ctx.client,
sessionID: extractedSessionId,
@@ -36,22 +50,39 @@ export async function syncBackgroundLaunchSessionTracking(input: {
return
}
appendSessionId(ctx.directory, trackedSessionId, "appended")
if (trackedWork) {
appendSessionIdForWork(ctx.directory, trackedWork.work_id, trackedSessionId, "appended")
} else {
appendSessionId(ctx.directory, trackedSessionId, "appended")
}
const { currentTask, shouldSkipTaskSessionUpdate } = resolveTaskContext(
pendingTaskRef,
boulderState.active_plan,
trackedWork
? resolveBoulderPlanPathForWork(ctx.directory, trackedWork)
: resolveBoulderPlanPath(ctx.directory, boulderState),
)
if (currentTask && !shouldSkipTaskSessionUpdate) {
upsertTaskSessionState(ctx.directory, {
taskKey: currentTask.key,
taskLabel: currentTask.label,
taskTitle: currentTask.title,
sessionId: trackedSessionId,
agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined,
category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined,
})
if (trackedWork) {
upsertTaskSessionStateForWork(ctx.directory, trackedWork.work_id, {
taskKey: currentTask.key,
taskLabel: currentTask.label,
taskTitle: currentTask.title,
sessionId: trackedSessionId,
agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined,
category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined,
})
} else {
upsertTaskSessionState(ctx.directory, {
taskKey: currentTask.key,
taskLabel: currentTask.label,
taskTitle: currentTask.title,
sessionId: trackedSessionId,
agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined,
category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined,
})
}
}
log(`[${HOOK_NAME}] Background launch session tracked`, {
@@ -81,17 +112,3 @@ async function resolveFallbackTrackedSessionId(input: {
return undefined
}
}
async function resolveSessionOrigin(
ctx: PluginInput,
sessionID: string,
): Promise<"direct" | "appended"> {
try {
const session = await ctx.client.session.get({ path: { id: sessionID } })
return typeof session.data?.parentID === "string" && session.data.parentID.length > 0
? "appended"
: "direct"
} catch {
return "appended"
}
}
+64 -55
View File
@@ -7,6 +7,8 @@ import type { PluginInput } from "@opencode-ai/plugin"
import { createAtlasHook } from "./atlas-hook"
import { clearBoulderState, writeBoulderState } from "../../features/boulder-state"
import { _resetForTesting, clearSessionAgent, registerAgentName, setSessionAgent } from "../../features/claude-code-session-state"
import { DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS } from "../../shared/prompt-async-gate"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
// Force process isolation in CI runner (globalThis.setTimeout override conflicts with other atlas tests)
mock.module("../../shared/opencode-storage-detection", () => ({
@@ -23,6 +25,8 @@ describe("atlas background task retry", () => {
let nextFakeTimerId = 1000
const originalSetTimeout = globalThis.setTimeout
const originalClearTimeout = globalThis.clearTimeout
const originalDateNow = Date.now
let fakeNow = 0
async function flushMicrotasks(): Promise<void> {
await Promise.resolve()
@@ -51,6 +55,7 @@ describe("atlas background task retry", () => {
}
capturedTimers.delete(id)
fakeNow += 6000
await entry.callback()
}
await flushMicrotasks()
@@ -66,6 +71,8 @@ describe("atlas background task retry", () => {
capturedTimers.clear()
nextFakeTimerId = 1000
fakeNow = 10_000
Date.now = () => fakeNow
globalThis.setTimeout = ((callback: Parameters<typeof setTimeout>[0], delay?: number, ...args: unknown[]) => {
const normalizedDelay = typeof delay === "number" ? delay : 0
@@ -73,21 +80,22 @@ describe("atlas background task retry", () => {
return originalSetTimeout(callback, delay, ...args)
}
if (normalizedDelay >= 5000) {
if (normalizedDelay >= 5000 && normalizedDelay !== DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS) {
const id = nextFakeTimerId++
capturedTimers.set(id, {
callback: () => (callback as LongTimerCallback)(...args),
cleared: false,
})
return id as unknown as ReturnType<typeof setTimeout>
return unsafeTestValue<ReturnType<typeof setTimeout>>(id)
}
return originalSetTimeout(callback, delay, ...args)
}) as typeof setTimeout
globalThis.clearTimeout = ((id?: number | ReturnType<typeof setTimeout>) => {
if (typeof id === "number" && capturedTimers.has(id)) {
capturedTimers.get(id)!.cleared = true
const timerEntry = typeof id === "number" ? capturedTimers.get(id) : undefined
if (timerEntry) {
timerEntry.cleared = true
capturedTimers.delete(id)
return
}
@@ -99,6 +107,7 @@ describe("atlas background task retry", () => {
afterEach(() => {
globalThis.setTimeout = originalSetTimeout
globalThis.clearTimeout = originalClearTimeout
Date.now = originalDateNow
_resetForTesting()
clearBoulderState(testDir)
if (existsSync(testDir)) {
@@ -120,7 +129,7 @@ describe("atlas background task retry", () => {
let backgroundRunning = true
const promptMock = mock(async () => ({}))
const hook = createAtlasHook({
const hook = createAtlasHook(unsafeTestValue<PluginInput>({
directory: testDir,
client: {
session: {
@@ -128,13 +137,13 @@ describe("atlas background task retry", () => {
messages: async () => ({ data: [] }),
},
},
} as unknown as PluginInput, {
}), {
directory: testDir,
backgroundManager: {
getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [],
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
backgroundManager: unsafeTestValue<NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
},
}>({
getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [],
}),
})
// when
@@ -161,7 +170,7 @@ describe("atlas background task retry", () => {
let backgroundRunning = true
const promptMock = mock(async () => ({}))
const hook = createAtlasHook({
const hook = createAtlasHook(unsafeTestValue<PluginInput>({
directory: testDir,
client: {
session: {
@@ -169,13 +178,13 @@ describe("atlas background task retry", () => {
messages: async () => ({ data: [] }),
},
},
} as unknown as PluginInput, {
}), {
directory: testDir,
backgroundManager: {
getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [],
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
backgroundManager: unsafeTestValue<NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
},
}>({
getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [],
}),
})
// when
@@ -204,7 +213,7 @@ describe("atlas background task retry", () => {
let remainingRunningRetries = 2
const promptMock = mock(async () => ({}))
const hook = createAtlasHook({
const hook = createAtlasHook(unsafeTestValue<PluginInput>({
directory: testDir,
client: {
session: {
@@ -212,9 +221,11 @@ describe("atlas background task retry", () => {
messages: async () => ({ data: [] }),
},
},
} as unknown as PluginInput, {
}), {
directory: testDir,
backgroundManager: {
backgroundManager: unsafeTestValue<NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
}>({
getTasksByParentSession: () => {
if (remainingRunningRetries > 0) {
remainingRunningRetries -= 1
@@ -223,9 +234,7 @@ describe("atlas background task retry", () => {
return []
},
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
},
}),
})
// when
@@ -258,7 +267,7 @@ describe("atlas background task retry", () => {
const promptAsyncMock = mock(async () => ({}))
let backgroundCheckCount = 0
const hook = createAtlasHook({
const hook = createAtlasHook(unsafeTestValue<PluginInput>({
directory: testDir,
client: {
session: {
@@ -266,9 +275,11 @@ describe("atlas background task retry", () => {
messages: async () => ({ data: [] }),
},
},
} as unknown as PluginInput, {
}), {
directory: testDir,
backgroundManager: {
backgroundManager: unsafeTestValue<NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
}>({
getTasksByParentSession: () => {
backgroundCheckCount += 1
if (backgroundCheckCount === 1) {
@@ -281,9 +292,7 @@ describe("atlas background task retry", () => {
return []
},
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
},
}),
})
// when
@@ -313,7 +322,7 @@ describe("atlas background task retry", () => {
let backgroundRunning = true
const promptAsyncMock = mock(async () => ({}))
const hook = createAtlasHook({
const hook = createAtlasHook(unsafeTestValue<PluginInput>({
directory: testDir,
client: {
session: {
@@ -321,13 +330,13 @@ describe("atlas background task retry", () => {
messages: async () => ({ data: [] }),
},
},
} as unknown as PluginInput, {
}), {
directory: testDir,
backgroundManager: {
getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [],
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
backgroundManager: unsafeTestValue<NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
},
}>({
getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [],
}),
})
// when
@@ -366,7 +375,7 @@ describe("atlas background task retry", () => {
let backgroundRunning = true
let descendantAgent = "atlas"
const promptAsyncMock = mock(async () => ({}))
const hook = createAtlasHook({
const hook = createAtlasHook(unsafeTestValue<PluginInput>({
directory: testDir,
client: {
session: {
@@ -384,18 +393,18 @@ describe("atlas background task retry", () => {
}),
},
},
} as unknown as PluginInput, {
}), {
directory: testDir,
backgroundManager: {
backgroundManager: unsafeTestValue<NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
}>({
getTasksByParentSession: (currentSessionID: string) => {
if (currentSessionID !== descendantSessionID) {
return []
}
return backgroundRunning ? [{ status: "running" }] : []
},
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
},
}),
})
// when
@@ -422,9 +431,9 @@ describe("atlas background task retry", () => {
agent: "atlas",
})
const deferredPrompt = createDeferred<{}>()
const deferredPrompt = createDeferred<unknown>()
const promptAsyncMock = mock(() => deferredPrompt.promise)
const hook = createAtlasHook({
const hook = createAtlasHook(unsafeTestValue<PluginInput>({
directory: testDir,
client: {
session: {
@@ -432,7 +441,7 @@ describe("atlas background task retry", () => {
messages: async () => ({ data: [] }),
},
},
} as unknown as PluginInput)
}))
// when
const firstIdle = hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
@@ -462,7 +471,7 @@ describe("atlas background task retry", () => {
promptAsyncMock.mockImplementationOnce(() => deferredPrompt.promise)
promptAsyncMock.mockImplementationOnce(async () => ({}))
const hook = createAtlasHook({
const hook = createAtlasHook(unsafeTestValue<PluginInput>({
directory: testDir,
client: {
session: {
@@ -470,13 +479,13 @@ describe("atlas background task retry", () => {
messages: async () => ({ data: [] }),
},
},
} as unknown as PluginInput, {
}), {
directory: testDir,
backgroundManager: {
getTasksByParentSession: () => [],
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
backgroundManager: unsafeTestValue<NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
},
}>({
getTasksByParentSession: () => [],
}),
})
// when
@@ -515,7 +524,7 @@ describe("atlas background task retry", () => {
})
promptAsyncMock.mockImplementationOnce(async () => ({}))
const hook = createAtlasHook({
const hook = createAtlasHook(unsafeTestValue<PluginInput>({
directory: testDir,
client: {
session: {
@@ -523,13 +532,13 @@ describe("atlas background task retry", () => {
messages: async () => ({ data: [] }),
},
},
} as unknown as PluginInput, {
}), {
directory: testDir,
backgroundManager: {
getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [],
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
backgroundManager: unsafeTestValue<NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
},
}>({
getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [],
}),
})
// when
@@ -2,6 +2,7 @@ import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import { registerAgentName, _resetForTesting } from "../../features/claude-code-session-state"
import { injectBoulderContinuation } from "./boulder-continuation-injector"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
describe("injectBoulderContinuation", () => {
beforeEach(() => {
@@ -20,7 +21,7 @@ describe("injectBoulderContinuation", () => {
const promptAsyncMock = mock(async (_request: unknown) => undefined)
const messagesMock = mock(async () => ({ data: [] }))
const ctx = {
const ctx = unsafeTestValue<PluginInput>({
directory: "/tmp",
client: {
session: {
@@ -28,7 +29,7 @@ describe("injectBoulderContinuation", () => {
promptAsync: promptAsyncMock,
},
},
} as unknown as PluginInput
})
// when
const result = await injectBoulderContinuation({
@@ -60,7 +61,7 @@ describe("injectBoulderContinuation", () => {
const messagesMock = mock(async () => ({ data: [] }))
const sessionState = { promptFailureCount: 2, lastContinuationInjectedAt: 123 }
const ctx = {
const ctx = unsafeTestValue<PluginInput>({
directory: "/tmp",
client: {
session: {
@@ -68,7 +69,7 @@ describe("injectBoulderContinuation", () => {
promptAsync: promptAsyncMock,
},
},
} as unknown as PluginInput
})
// when
const result = await injectBoulderContinuation({
@@ -78,9 +79,9 @@ describe("injectBoulderContinuation", () => {
remaining: 1,
total: 2,
agent: "atlas",
backgroundManager: {
backgroundManager: unsafeTestValue<Parameters<typeof injectBoulderContinuation>[0]["backgroundManager"]>({
getTasksByParentSession: () => [{ status: "running" }],
} as unknown as Parameters<typeof injectBoulderContinuation>[0]["backgroundManager"],
}),
sessionState,
})
@@ -91,12 +92,14 @@ describe("injectBoulderContinuation", () => {
expect(sessionState.lastContinuationInjectedAt).toBe(123)
})
test("#given the continuation agent is unavailable #when injector runs #then it reports skipped agent unavailable without prompting", async () => {
test("#given a background task is still pending session creation #when injector checks again #then it still skips continuation", async () => {
// given
registerAgentName("atlas")
const promptAsyncMock = mock(async (_request: unknown) => undefined)
const messagesMock = mock(async () => ({ data: [] }))
const sessionState = { promptFailureCount: 1, lastContinuationInjectedAt: 456 }
const ctx = {
const ctx = unsafeTestValue<PluginInput>({
directory: "/tmp",
client: {
session: {
@@ -104,7 +107,43 @@ describe("injectBoulderContinuation", () => {
promptAsync: promptAsyncMock,
},
},
} as unknown as PluginInput
})
// when
const result = await injectBoulderContinuation({
ctx,
sessionID: "ses_test_pending",
planName: "test-plan",
remaining: 1,
total: 2,
agent: "atlas",
backgroundManager: unsafeTestValue<Parameters<typeof injectBoulderContinuation>[0]["backgroundManager"]>({
getTasksByParentSession: () => [{ status: "pending" }],
}),
sessionState,
})
// then
expect(result).toBe("skipped_background_tasks")
expect(promptAsyncMock).not.toHaveBeenCalled()
expect(sessionState.promptFailureCount).toBe(1)
expect(sessionState.lastContinuationInjectedAt).toBe(456)
})
test("#given the continuation agent is unavailable #when injector runs #then it reports skipped agent unavailable without prompting", async () => {
// given
const promptAsyncMock = mock(async (_request: unknown) => undefined)
const messagesMock = mock(async () => ({ data: [] }))
const ctx = unsafeTestValue<PluginInput>({
directory: "/tmp",
client: {
session: {
messages: messagesMock,
promptAsync: promptAsyncMock,
},
},
})
// when
const result = await injectBoulderContinuation({
@@ -129,6 +168,11 @@ describe("injectBoulderContinuation", () => {
body?: {
model?: { providerID: string; modelID: string }
variant?: string
noReply?: boolean
parts?: Array<{
synthetic?: boolean
metadata?: Record<string, unknown>
}>
}
}> = []
const promptAsyncMock = mock(async (request: unknown) => {
@@ -151,7 +195,7 @@ describe("injectBoulderContinuation", () => {
}],
}))
const ctx = {
const ctx = unsafeTestValue<PluginInput>({
directory: "/tmp",
client: {
session: {
@@ -159,7 +203,7 @@ describe("injectBoulderContinuation", () => {
promptAsync: promptAsyncMock,
},
},
} as unknown as PluginInput
})
// when
const result = await injectBoulderContinuation({
@@ -180,5 +224,9 @@ describe("injectBoulderContinuation", () => {
modelID: "claude-sonnet-4-20250514",
})
expect(capturedRequests[0]?.body?.variant).toBe("max")
expect(capturedRequests[0]?.body?.noReply).toBeUndefined()
const promptPart = capturedRequests[0]?.body?.parts?.[0]
expect(promptPart?.synthetic).toBe(true)
expect(promptPart?.metadata?.compaction_continue).toBe(true)
})
})
@@ -1,17 +1,25 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundManager } from "../../features/background-agent"
import {
isAgentRegistered,
resolveRegisteredAgentName,
} from "../../features/claude-code-session-state"
import { stripAgentListSortPrefix } from "../../shared/agent-display-names"
import { log } from "../../shared/logger"
import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared"
import { createInternalAgentContinuationTextPart, resolveInheritedPromptTools } from "../../shared"
import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate"
import { HOOK_NAME } from "./hook-name"
import { BOULDER_CONTINUATION_PROMPT } from "./system-reminder-templates"
import { resolveRecentPromptContextForSession } from "./recent-model-resolver"
import type { SessionState } from "./types"
import type { BackgroundTaskStatusProvider, SessionState } from "./types"
export type BoulderContinuationResult = "injected" | "skipped_background_tasks" | "skipped_agent_unavailable" | "failed"
export type BoulderContinuationResult =
| "injected"
| "skipped_active_session"
| "skipped_background_tasks"
| "skipped_agent_unavailable"
| "failed"
const ACTIVE_BACKGROUND_TASK_STATUSES = new Set(["pending", "running"])
export async function injectBoulderContinuation(input: {
ctx: PluginInput
@@ -23,8 +31,9 @@ export async function injectBoulderContinuation(input: {
worktreePath?: string
preferredTaskSessionId?: string
preferredTaskTitle?: string
backgroundManager?: BackgroundManager
backgroundManager?: BackgroundTaskStatusProvider
sessionState: SessionState
idleSettleMs?: number
}): Promise<BoulderContinuationResult> {
const {
ctx,
@@ -38,10 +47,11 @@ export async function injectBoulderContinuation(input: {
preferredTaskTitle,
backgroundManager,
sessionState,
idleSettleMs,
} = input
const hasRunningBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some((t: { status: string }) => t.status === "running")
? backgroundManager.getTasksByParentSession(sessionID).some((t: { status: string }) => ACTIVE_BACKGROUND_TASK_STATUSES.has(t.status))
: false
if (hasRunningBgTasks) {
@@ -58,20 +68,21 @@ export async function injectBoulderContinuation(input: {
`\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]` +
preferredSessionContext +
worktreeContext
const continuationAgent = resolveRegisteredAgentName(
const resolvedContinuationAgent = resolveRegisteredAgentName(
agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined),
)
const continuationAgent = resolvedContinuationAgent ? stripAgentListSortPrefix(resolvedContinuationAgent) : resolvedContinuationAgent
if (!continuationAgent || !isAgentRegistered(continuationAgent)) {
log(`[${HOOK_NAME}] Skipped injection: continuation agent unavailable`, {
sessionID,
agent: continuationAgent ?? agent ?? "unknown",
})
return "skipped_agent_unavailable"
}
return "skipped_agent_unavailable"
}
try {
log(`[${HOOK_NAME}] Injecting boulder continuation`, { sessionID, planName, remaining })
try {
log(`[${HOOK_NAME}] Injecting boulder continuation`, { sessionID, planName, remaining })
const promptContext = await resolveRecentPromptContextForSession(ctx, sessionID)
const inheritedTools = resolveInheritedPromptTools(sessionID, promptContext.tools)
@@ -81,17 +92,33 @@ export async function injectBoulderContinuation(input: {
: undefined
const launchVariant = promptContext.model?.variant
await ctx.client.session.promptAsync({
const promptResult = await promptAsyncAfterSessionIdle({
client: ctx.client,
sessionID,
source: HOOK_NAME,
settleMs: idleSettleMs,
input: {
path: { id: sessionID },
body: {
agent: continuationAgent,
...(launchModel ? { model: launchModel } : {}),
...(launchVariant ? { variant: launchVariant } : {}),
...(inheritedTools ? { tools: inheritedTools } : {}),
parts: [createInternalAgentTextPart(prompt)],
parts: [createInternalAgentContinuationTextPart(prompt)],
},
query: { directory: ctx.directory },
},
})
if (promptResult.status === "failed") {
throw promptResult.error
}
if (promptResult.status !== "dispatched") {
log(`[${HOOK_NAME}] Boulder continuation skipped by promptAsync gate`, {
sessionID,
status: promptResult.status,
})
return "skipped_active_session"
}
sessionState.promptFailureCount = 0
log(`[${HOOK_NAME}] Boulder continuation injected`, { sessionID })
+25 -11
View File
@@ -1,5 +1,6 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "../../shared/logger"
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
import { HOOK_NAME } from "./hook-name"
import { isAbortError } from "./is-abort-error"
import { handleAtlasSessionIdle } from "./idle-event"
@@ -17,7 +18,7 @@ export function createAtlasEventHandler(input: {
const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.error") {
const sessionID = props?.sessionID as string | undefined
const sessionID = resolveSessionEventID(props)
if (!sessionID) return
const state = getState(sessionID)
@@ -25,11 +26,21 @@ export function createAtlasEventHandler(input: {
state.lastEventWasAbortError = isAbort
log(`[${HOOK_NAME}] session.error`, { sessionID, isAbort })
if (!isAbort) {
const previousInjectedAt = state.lastContinuationInjectedAt
await handleAtlasSessionIdle({ ctx, options, getState, sessionID })
if (
state.lastContinuationInjectedAt !== undefined
&& state.lastContinuationInjectedAt !== previousInjectedAt
) {
state.skipNextIdleAfterRuntimeErrorRetry = true
}
}
return
}
if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined
const sessionID = resolveSessionEventID(props)
if (!sessionID) return
await handleAtlasSessionIdle({ ctx, options, getState, sessionID })
return
@@ -37,13 +48,14 @@ export function createAtlasEventHandler(input: {
if (event.type === "message.updated") {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined
const sessionID = resolveMessageEventSessionID(props)
const role = info?.role as string | undefined
if (!sessionID) return
const state = sessions.get(sessionID)
if (state) {
state.lastEventWasAbortError = false
state.skipNextIdleAfterRuntimeErrorRetry = false
if (role === "user") {
state.waitingForFinalWaveApproval = false
}
@@ -53,44 +65,46 @@ export function createAtlasEventHandler(input: {
if (event.type === "message.part.updated") {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined
const sessionID = resolveMessageEventSessionID(props)
const role = info?.role as string | undefined
if (sessionID && role === "assistant") {
const state = sessions.get(sessionID)
if (state) {
state.lastEventWasAbortError = false
state.skipNextIdleAfterRuntimeErrorRetry = false
}
}
return
}
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
const sessionID = props?.sessionID as string | undefined
const sessionID = resolveMessageEventSessionID(props)
if (sessionID) {
const state = sessions.get(sessionID)
if (state) {
state.lastEventWasAbortError = false
state.skipNextIdleAfterRuntimeErrorRetry = false
}
}
return
}
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id) {
const deletedState = sessions.get(sessionInfo.id)
const sessionID = resolveSessionEventID(props)
if (sessionID) {
const deletedState = sessions.get(sessionID)
if (deletedState?.pendingRetryTimer) {
clearTimeout(deletedState.pendingRetryTimer)
}
sessions.delete(sessionInfo.id)
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id })
sessions.delete(sessionID)
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID })
}
return
}
if (event.type === "session.compacted") {
const sessionID = (props?.sessionID ?? (props?.info as { id?: string } | undefined)?.id) as string | undefined
const sessionID = resolveSessionEventID(props)
if (sessionID) {
const compactedState = sessions.get(sessionID)
if (compactedState?.pendingRetryTimer) {
@@ -113,7 +113,7 @@ describe("Atlas final-wave approval gate regressions", () => {
beforeEach(() => {
testDirectory = join(tmpdir(), `atlas-final-wave-regression-${randomUUID()}`)
mkdirSync(join(testDirectory, ".sisyphus"), { recursive: true })
mkdirSync(join(testDirectory, ".omo"), { recursive: true })
clearBoulderState(testDirectory)
})
@@ -149,7 +149,10 @@ describe("Atlas final-wave approval gate regressions", () => {
- [ ] All tests pass
`)
const hook = createAtlasHook(createMockPluginInput())
const hook = createAtlasHook(createMockPluginInput(), {
directory: testDirectory,
isCallerOrchestrator: async () => true,
})
const toolOutput = {
title: "Sisyphus Task",
output: `Tasks [1/1 compliant] | Contamination [CLEAN] | Unaccounted [CLEAN] | VERDICT: APPROVE
@@ -186,7 +189,10 @@ session_id: ses_nested_scope_review
- [ ] F4. **Scope Fidelity Check** - \`deep\`
`)
const hook = createAtlasHook(createMockPluginInput())
const hook = createAtlasHook(createMockPluginInput(), {
directory: testDirectory,
isCallerOrchestrator: async () => true,
})
const firstThreeOutputs = [1, 2, 3].map((index) => ({
title: `Final review ${index}`,
output: `Reviewer ${index} | VERDICT: APPROVE
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, mock, test, afterAll } from "bun:test"
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import { randomUUID } from "node:crypto"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
@@ -7,32 +7,7 @@ import { createOpencodeClient } from "@opencode-ai/sdk"
import type { AssistantMessage, Session } from "@opencode-ai/sdk"
import type { BoulderState } from "../../features/boulder-state"
import { clearBoulderState, writeBoulderState } from "../../features/boulder-state"
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-final-wave-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 directoryPath = join(TEST_MESSAGE_STORAGE, sessionID)
return existsSync(directoryPath) ? directoryPath : null
},
}))
mock.module("../../shared/opencode-storage-detection", () => ({
isSqliteBackend: () => false,
}))
afterAll(() => { mock.restore() })
const { createAtlasHook } = await import("./index")
const { MESSAGE_STORAGE } = await import("../../features/hook-message-injector")
import { createAtlasHook } from "./index"
type AtlasHookContext = Parameters<typeof createAtlasHook>[0]
type PromptMock = ReturnType<typeof mock>
@@ -89,31 +64,9 @@ describe("Atlas final verification approval gate", () => {
}
}
function setupMessageStorage(sessionID: string): void {
const messageDirectory = join(MESSAGE_STORAGE, sessionID)
if (!existsSync(messageDirectory)) {
mkdirSync(messageDirectory, { recursive: true })
}
writeFileSync(
join(messageDirectory, "msg_test001.json"),
JSON.stringify({
agent: "atlas",
model: { providerID: "anthropic", modelID: "claude-opus-4-7" },
}),
)
}
function cleanupMessageStorage(sessionID: string): void {
const messageDirectory = join(MESSAGE_STORAGE, sessionID)
if (existsSync(messageDirectory)) {
rmSync(messageDirectory, { recursive: true, force: true })
}
}
beforeEach(() => {
testDirectory = join(tmpdir(), `atlas-final-wave-test-${randomUUID()}`)
mkdirSync(join(testDirectory, ".sisyphus"), { recursive: true })
mkdirSync(join(testDirectory, ".omo"), { recursive: true })
clearBoulderState(testDirectory)
})
@@ -127,7 +80,6 @@ describe("Atlas final verification approval gate", () => {
test("waits for explicit user approval after the last final-wave approval arrives", async () => {
// given
const sessionID = "atlas-final-wave-session"
setupMessageStorage(sessionID)
const planPath = join(testDirectory, "final-wave-plan.md")
writeFileSync(
@@ -155,7 +107,7 @@ describe("Atlas final verification approval gate", () => {
writeBoulderState(testDirectory, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createAtlasHook(mockInput, { directory: testDirectory, isCallerOrchestrator: async () => true })
const toolOutput = {
title: "Sisyphus Task",
output: `Tasks [4/4 compliant] | Contamination [CLEAN] | Unaccounted [CLEAN] | VERDICT: APPROVE
@@ -176,13 +128,11 @@ session_id: ses_final_wave_review
expect(toolOutput.output).not.toContain("STEP 8: PROCEED TO NEXT TASK")
expect(mockInput._promptMock).not.toHaveBeenCalled()
cleanupMessageStorage(sessionID)
})
test("keeps normal auto-continue instructions for non-final tasks", async () => {
// given
const sessionID = "atlas-non-final-session"
setupMessageStorage(sessionID)
const planPath = join(testDirectory, "implementation-plan.md")
writeFileSync(
@@ -210,7 +160,10 @@ session_id: ses_final_wave_review
}
writeBoulderState(testDirectory, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createAtlasHook(createMockPluginInput(), {
directory: testDirectory,
isCallerOrchestrator: async () => true,
})
const toolOutput = {
title: "Sisyphus Task",
output: `Implementation finished successfully
@@ -229,6 +182,5 @@ session_id: ses_feature_task
expect(toolOutput.output).toContain("STEP 8: PROCEED TO NEXT TASK")
expect(toolOutput.output).not.toContain("FINAL WAVE APPROVAL GATE")
cleanupMessageStorage(sessionID)
})
})
@@ -0,0 +1,79 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import { randomUUID } from "node:crypto"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { clearBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
const { createAtlasHook } = await import("./index")
describe("atlas hook idle-event complete boulder", () => {
let testDirectory = ""
beforeEach(() => {
testDirectory = join(tmpdir(), `atlas-idle-complete-${randomUUID()}`)
if (!existsSync(testDirectory)) {
mkdirSync(testDirectory, { recursive: true })
}
clearBoulderState(testDirectory)
})
afterEach(() => {
clearBoulderState(testDirectory)
if (existsSync(testDirectory)) {
rmSync(testDirectory, { recursive: true, force: true })
}
})
it("marks work completed with ended_at and elapsed_ms when progress is complete", async () => {
// given
const sessionID = "ses_complete"
const planPath = join(testDirectory, "complete-plan.md")
writeFileSync(planPath, "# Plan\n\n## TODOs\n- [x] 1. Done\n", "utf-8")
writeBoulderState(testDirectory, {
schema_version: 2,
active_work_id: "work-complete",
active_plan: planPath,
started_at: "2026-01-02T10:00:00.000Z",
session_ids: [sessionID],
plan_name: "complete-plan",
works: {
"work-complete": {
work_id: "work-complete",
active_plan: planPath,
plan_name: "complete-plan",
started_at: "2026-01-02T10:00:00.000Z",
session_ids: [sessionID],
status: "active",
},
},
})
const hook = createAtlasHook(unsafeTestValue<Parameters<typeof createAtlasHook>[0]>({
directory: testDirectory,
client: {
session: {
get: async () => ({ data: { id: sessionID } }),
messages: async () => ({ data: [] }),
prompt: async () => ({ data: {} }),
promptAsync: async () => ({ data: {} }),
},
},
}))
// when
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID },
},
})
// then
const work = readBoulderState(testDirectory)?.works?.["work-complete"]
expect(work?.status).toBe("completed")
expect(work?.ended_at).toBeString()
expect((work?.elapsed_ms ?? 0) > 0).toBe(true)
})
})
+3 -2
View File
@@ -7,6 +7,7 @@ import { join } from "node:path"
import { clearBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state"
import type { BoulderState } from "../../features/boulder-state"
import { _resetForTesting, registerAgentName, setSessionAgent, subagentSessions } from "../../features/claude-code-session-state"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
const { createAtlasHook } = await import("./index")
@@ -32,7 +33,7 @@ describe("atlas hook idle-event session lineage", () => {
}
function createHook(parentSessionIDs?: Record<string, string | undefined>) {
return createAtlasHook({
return createAtlasHook(unsafeTestValue<Parameters<typeof createAtlasHook>[0]>({
directory: testDirectory,
client: {
session: {
@@ -52,7 +53,7 @@ describe("atlas hook idle-event session lineage", () => {
},
},
},
} as unknown as Parameters<typeof createAtlasHook>[0])
}))
}
beforeEach(() => {
@@ -8,6 +8,7 @@ import { randomUUID } from "node:crypto"
import { clearBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state"
import { _resetForTesting, registerAgentName } from "../../features/claude-code-session-state"
import type { BoulderState } from "../../features/boulder-state"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-persisted-lineage-storage-${randomUUID()}`)
const TEST_MESSAGE_STORAGE = join(TEST_STORAGE_ROOT, "message")
@@ -58,7 +59,7 @@ describe("atlas hook idle-event persisted lineage", () => {
parentSessionIDs?: Record<string, string | undefined>,
messagesBySession?: Record<string, Array<{ info: { agent: string; providerID: string; modelID: string } }>>,
) {
return createAtlasHook({
return createAtlasHook(unsafeTestValue<Parameters<typeof createAtlasHook>[0]>({
directory: testDirectory,
client: {
session: {
@@ -79,7 +80,7 @@ describe("atlas hook idle-event persisted lineage", () => {
},
},
},
} as unknown as Parameters<typeof createAtlasHook>[0])
}))
}
beforeEach(() => {
@@ -173,7 +174,7 @@ describe("atlas hook idle-event persisted lineage", () => {
},
})
const hook = createAtlasHook({
const hook = createAtlasHook(unsafeTestValue<Parameters<typeof createAtlasHook>[0]>({
directory: testDirectory,
client: {
session: {
@@ -193,7 +194,7 @@ describe("atlas hook idle-event persisted lineage", () => {
},
},
},
} as unknown as Parameters<typeof createAtlasHook>[0])
}))
// when
await hook.handler({
+147
View File
@@ -0,0 +1,147 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import { randomUUID } from "node:crypto"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { createBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state"
import { _resetForTesting, registerAgentName } from "../../features/claude-code-session-state"
import { handleAtlasSessionIdle } from "./idle-event"
import type { SessionState } from "./types"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
describe("handleAtlasSessionIdle completion nudge", () => {
const SESSION_ID = "session-main-1"
let testDirectory = ""
beforeEach(() => {
testDirectory = join(tmpdir(), `atlas-idle-complete-${randomUUID()}`)
if (!existsSync(testDirectory)) {
mkdirSync(testDirectory, { recursive: true })
}
_resetForTesting()
registerAgentName("atlas")
})
afterEach(() => {
if (existsSync(testDirectory)) {
rmSync(testDirectory, { recursive: true, force: true })
}
_resetForTesting()
})
it("injects BOULDER COMPLETE prompt once per work with substituted elapsed and task breakdown", async () => {
// given
const planPath = join(testDirectory, "plan.md")
writeFileSync(planPath, "## TODOs\n- [x] 1. Parse input\n- [x] 2. Save output\n")
const boulder = createBoulderState(planPath, SESSION_ID, "atlas")
const workId = boulder.active_work_id
if (!workId) {
throw new Error("Expected active_work_id")
}
const work = boulder.works?.[workId]
if (!work) {
throw new Error("Expected active work")
}
work.elapsed_ms = 65_000
boulder.elapsed_ms = 65_000
work.task_sessions = {
"todo:2": {
task_key: "todo:2",
task_label: "2",
task_title: "Save output",
session_id: "sub-2",
elapsed_ms: 4_000,
updated_at: new Date().toISOString(),
},
"todo:1": {
task_key: "todo:1",
task_label: "1",
task_title: "Parse input",
session_id: "sub-1",
elapsed_ms: 61_000,
updated_at: new Date().toISOString(),
},
}
boulder.task_sessions = work.task_sessions
writeBoulderState(testDirectory, boulder)
const promptRequests: Array<{
body?: {
noReply?: boolean
parts?: Array<{
text?: string
synthetic?: boolean
metadata?: Record<string, unknown>
}>
}
}> = []
const promptAsyncMock = mock(async (request: {
body?: {
noReply?: boolean
parts?: Array<{
text?: string
synthetic?: boolean
metadata?: Record<string, unknown>
}>
}
}) => {
promptRequests.push(request)
return { data: {} }
})
const ctx = unsafeTestValue<PluginInput>({
directory: testDirectory,
client: {
session: {
promptAsync: promptAsyncMock,
},
},
})
const sessionStateById = new Map<string, SessionState>()
const getState = (sessionId: string): SessionState => {
let state = sessionStateById.get(sessionId)
if (!state) {
state = { promptFailureCount: 0 }
sessionStateById.set(sessionId, state)
}
return state
}
// when
await handleAtlasSessionIdle({
ctx,
sessionID: SESSION_ID,
getState,
})
await handleAtlasSessionIdle({
ctx,
sessionID: SESSION_ID,
getState,
})
// then
expect(promptAsyncMock).toHaveBeenCalledTimes(1)
const promptText = promptRequests[0]?.body?.parts?.[0]?.text ?? ""
expect(promptText).toContain("BOULDER COMPLETE")
expect(promptText).toContain("Total elapsed: 1m 5s")
expect(promptText).toContain("- 1 Parse input: 1m 1s")
expect(promptText).toContain("- 2 Save output: 4s")
expect(promptText).not.toContain("{ELAPSED_HUMAN}")
expect(promptRequests[0]?.body?.noReply).toBeUndefined()
expect(promptRequests[0]?.body?.parts?.[0]?.synthetic).toBe(true)
expect(promptRequests[0]?.body?.parts?.[0]?.metadata?.compaction_continue).toBe(true)
const persistedState = getState(SESSION_ID)
expect(persistedState.boulderCompletionNudgedAt?.[workId]).toBeNumber()
expect(readBoulderState(testDirectory)?.works?.[workId]?.status).toBe("completed")
})
})
+119 -4
View File
@@ -1,18 +1,30 @@
import type { PluginInput } from "@opencode-ai/plugin"
import {
completeBoulder,
formatDurationHuman,
getPlanProgress,
getWorkForSession,
getTaskSessionState,
readBoulderState,
readCurrentTopLevelTask,
resolveBoulderPlanPath,
} from "../../features/boulder-state"
import { getSessionAgent } from "../../features/claude-code-session-state"
import {
getSessionAgent,
isAgentRegistered,
resolveRegisteredAgentName,
} from "../../features/claude-code-session-state"
import { getLastAgentFromSession } from "./session-last-agent"
import { isSessionInBoulderLineage } from "./boulder-session-lineage"
import { createInternalAgentContinuationTextPart } from "../../shared"
import { getAgentConfigKey } from "../../shared/agent-display-names"
import { log } from "../../shared/logger"
import { shouldPromptAfterSessionIdle } from "../shared/session-idle-settle"
import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate"
import { injectBoulderContinuation } from "./boulder-continuation-injector"
import { HOOK_NAME } from "./hook-name"
import { resolveActiveBoulderSession } from "./resolve-active-boulder-session"
import { BOULDER_COMPLETE_PROMPT } from "./system-reminder-templates"
import type { AtlasHookOptions, SessionState } from "./types"
const CONTINUATION_COOLDOWN_MS = 5000
@@ -20,6 +32,11 @@ const FAILURE_BACKOFF_MS = 5 * 60 * 1000
const MAX_CONSECUTIVE_PROMPT_FAILURES = 10
const RETRY_DELAY_MS = CONTINUATION_COOLDOWN_MS + 1000
function getTaskLabelSortValue(taskLabel: string): number {
const parsed = Number.parseInt(taskLabel.replace(/[^0-9]/g, ""), 10)
return Number.isNaN(parsed) ? Number.POSITIVE_INFINITY : parsed
}
function hasRunningBackgroundTasks(sessionID: string, options?: AtlasHookOptions): boolean {
const backgroundManager = options?.backgroundManager
return backgroundManager
@@ -36,6 +53,7 @@ async function injectContinuation(input: {
progress: { total: number; completed: number }
agent?: string
worktreePath?: string
idleSettleMs?: number
}): Promise<void> {
const remaining = input.progress.total - input.progress.completed
if (input.sessionState.isInjectingContinuation) {
@@ -52,8 +70,12 @@ async function injectContinuation(input: {
try {
const currentBoulder = readBoulderState(input.ctx.directory)
const currentPlanPath = currentBoulder
? resolveBoulderPlanPath(input.ctx.directory, currentBoulder)
: null
const currentTask = currentBoulder
? readCurrentTopLevelTask(currentBoulder.active_plan)
&& currentPlanPath
? readCurrentTopLevelTask(currentPlanPath)
: null
const preferredTaskSession = currentTask
? getTaskSessionState(input.ctx.directory, currentTask.key)
@@ -90,6 +112,7 @@ async function injectContinuation(input: {
preferredTaskTitle: preferredTaskSession?.task_title,
backgroundManager: input.options?.backgroundManager,
sessionState: input.sessionState,
idleSettleMs: input.idleSettleMs,
})
if (result === "injected") {
@@ -163,7 +186,7 @@ function scheduleRetry(input: {
if (!currentBoulder) return
if (!currentBoulder.session_ids?.includes(sessionID)) return
const currentProgress = getPlanProgress(currentBoulder.active_plan)
const currentProgress = getPlanProgress(resolveBoulderPlanPath(ctx.directory, currentBoulder))
if (currentProgress.isComplete) return
if (options?.isContinuationStopped?.(sessionID)) return
const canContinueSession = await canContinueTrackedBoulderSession({
@@ -199,6 +222,7 @@ export async function handleAtlasSessionIdle(input: {
sessionID: string
}): Promise<void> {
const { ctx, options, getState, sessionID } = input
const sessionState = getState(sessionID)
log(`[${HOOK_NAME}] session.idle`, { sessionID })
@@ -214,6 +238,86 @@ export async function handleAtlasSessionIdle(input: {
const { boulderState, progress, appendedSession } = activeBoulderSession
if (progress.isComplete) {
const work = getWorkForSession(ctx.directory, sessionID)
if (work) {
completeBoulder(ctx.directory, work.work_id)
} else {
completeBoulder(ctx.directory, boulderState.active_work_id)
}
if (!work || work.status === "abandoned") {
log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name })
return
}
if (sessionState.boulderCompletionNudgedAt?.[work.work_id]) {
log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name })
return
}
const elapsedMilliseconds = work.elapsed_ms ?? (Date.now() - new Date(work.started_at).getTime())
const elapsedHuman = formatDurationHuman(elapsedMilliseconds)
const taskBreakdown = Object.values(work.task_sessions ?? {})
.sort((left, right) => {
const leftSortValue = getTaskLabelSortValue(left.task_label)
const rightSortValue = getTaskLabelSortValue(right.task_label)
if (leftSortValue !== rightSortValue) {
return leftSortValue - rightSortValue
}
return left.task_label.localeCompare(right.task_label)
})
.map((task) => {
if (typeof task.elapsed_ms === "number") {
return `- ${task.task_label} ${task.task_title}: ${formatDurationHuman(task.elapsed_ms)}`
}
return `- ${task.task_label} ${task.task_title}: (no timing)`
})
.join("\n")
const prompt = BOULDER_COMPLETE_PROMPT
.replace(/{PLAN_NAME}/g, work.plan_name)
.replace(/{ELAPSED_HUMAN}/g, elapsedHuman)
.replace(/{TASK_BREAKDOWN}/g, taskBreakdown.length > 0 ? taskBreakdown : "- (no task timings)")
const atlasAgent = resolveRegisteredAgentName(
boulderState.agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined),
)
if (atlasAgent && isAgentRegistered(atlasAgent)) {
if (!(await shouldPromptAfterSessionIdle(ctx.client, sessionID, options?.idleSettleMs))) {
log(`[${HOOK_NAME}] Boulder completion nudge skipped because session is active`, { sessionID })
return
}
const promptResult = await promptAsyncAfterSessionIdle({
client: ctx.client,
sessionID,
source: HOOK_NAME,
settleMs: options?.idleSettleMs,
input: {
path: { id: sessionID },
body: {
agent: atlasAgent,
parts: [createInternalAgentContinuationTextPart(prompt)],
},
query: { directory: ctx.directory },
},
})
if (promptResult.status !== "dispatched") {
log(`[${HOOK_NAME}] Boulder completion nudge skipped by promptAsync gate`, {
sessionID,
status: promptResult.status,
})
return
}
sessionState.boulderCompletionNudgedAt = {
...(sessionState.boulderCompletionNudgedAt ?? {}),
[work.work_id]: Date.now(),
}
}
log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name })
return
}
@@ -240,7 +344,6 @@ export async function handleAtlasSessionIdle(input: {
return
}
const sessionState = getState(sessionID)
const now = Date.now()
if (sessionState.waitingForFinalWaveApproval) {
@@ -254,6 +357,12 @@ export async function handleAtlasSessionIdle(input: {
return
}
if (sessionState.skipNextIdleAfterRuntimeErrorRetry) {
sessionState.skipNextIdleAfterRuntimeErrorRetry = false
log(`[${HOOK_NAME}] Skipped: stale idle after runtime error retry`, { sessionID })
return
}
if (sessionState.promptFailureCount >= MAX_CONSECUTIVE_PROMPT_FAILURES) {
const timeSinceLastFailure =
sessionState.lastFailureAt !== undefined ? now - sessionState.lastFailureAt : Number.POSITIVE_INFINITY
@@ -291,6 +400,11 @@ export async function handleAtlasSessionIdle(input: {
return
}
if (!(await shouldPromptAfterSessionIdle(ctx.client, sessionID, options?.idleSettleMs))) {
log(`[${HOOK_NAME}] Skipped: session became active during idle settle`, { sessionID })
return
}
await injectContinuation({
ctx,
sessionID,
@@ -300,6 +414,7 @@ export async function handleAtlasSessionIdle(input: {
progress,
agent: boulderState.agent,
worktreePath: boulderState.worktree_path,
idleSettleMs: options?.idleSettleMs ?? 0,
})
}
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, test } from "bun:test"
import { isOmoPath } from "./omo-path"
describe("isOmoPath", () => {
test("#given a path under an omo directory #when checking the path #then it matches the omo segment", () => {
expect(isOmoPath(".omo/plans/work.md")).toBe(true)
expect(isOmoPath("/repo/.omo/plans/work.md")).toBe(true)
expect(isOmoPath(String.raw`C:\repo\.omo\plans\work.md`)).toBe(true)
})
test("#given a path whose directory merely ends with omo #when checking the path #then it does not match", () => {
expect(isOmoPath("/repo/work.omo/plans/work.md")).toBe(false)
expect(isOmoPath("/repo/.omo-backup/plans/work.md")).toBe(false)
expect(isOmoPath("/repo/notes.omo")).toBe(false)
})
})
+8
View File
@@ -0,0 +1,8 @@
/**
* Cross-platform check if a path is inside .omo/ directory.
* Handles both forward slashes (Unix) and backslashes (Windows).
* Uses path segment matching instead of substring matching.
*/
export function isOmoPath(filePath: string): boolean {
return /(^|[/\\])\.omo([/\\]|$)/.test(filePath)
}
@@ -1,26 +1,30 @@
declare const require: (name: string) => any
const { describe, expect, mock, test, afterAll } = require("bun:test")
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { afterAll, describe, expect, test } from "bun:test"
import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { resolveRecentPromptContextForSession } from "./recent-model-resolver"
import type { ModelInfo } from "./types"
const testDirs: string[] = []
const TEST_STORAGE_ROOT = join(tmpdir(), `recent-model-fallback-${Date.now()}`)
const TEST_MESSAGE_STORAGE = join(TEST_STORAGE_ROOT, "message")
mock.module("../../shared/opencode-storage-detection", () => ({
isSqliteBackend: () => false,
}))
function findNearestTestMessage(messageDir: string): { model?: ModelInfo; tools?: Record<string, boolean> } | null {
const [message] = readdirSync(messageDir)
.filter((fileName) => fileName.endsWith(".json"))
.map((fileName) => {
const content = readFileSync(join(messageDir, fileName), "utf-8")
const parsed = JSON.parse(content) as { model?: ModelInfo; tools?: Record<string, boolean>; time?: { created?: number } }
return {
message: parsed,
createdAt: parsed.time?.created ?? Number.NEGATIVE_INFINITY,
fileName,
}
})
.sort((left, right) => right.createdAt - left.createdAt || right.fileName.localeCompare(left.fileName))
mock.module("../../shared/opencode-message-dir", () => ({
getMessageDir: (sessionID: string) => {
const directPath = join(TEST_MESSAGE_STORAGE, sessionID)
return require("node:fs").existsSync(directPath) ? directPath : null
},
}))
return message?.message ?? null
}
afterAll(() => {
mock.restore()
while (testDirs.length > 0) {
const directory = testDirs.pop()
if (directory) {
@@ -34,8 +38,10 @@ describe("resolveRecentPromptContextForSession fallback ordering", () => {
// given
const sessionID = "ses_recent_model_fallback"
const directory = mkdtempSync(join(tmpdir(), "recent-model-fallback-dir-"))
const storageRoot = mkdtempSync(join(tmpdir(), "recent-model-fallback-storage-"))
testDirs.push(directory)
const messageDir = join(TEST_MESSAGE_STORAGE, sessionID)
testDirs.push(storageRoot)
const messageDir = join(storageRoot, sessionID)
mkdirSync(messageDir, { recursive: true })
writeFileSync(join(messageDir, "msg_ffff0000_000001.json"), JSON.stringify({
agent: "atlas",
@@ -50,8 +56,6 @@ describe("resolveRecentPromptContextForSession fallback ordering", () => {
time: { created: 100 },
}), "utf-8")
const { resolveRecentPromptContextForSession } = await import("./recent-model-resolver")
const ctx = {
client: {
session: {
@@ -63,7 +67,12 @@ describe("resolveRecentPromptContextForSession fallback ordering", () => {
}
// when
const result = await resolveRecentPromptContextForSession(ctx as never, sessionID)
const result = await resolveRecentPromptContextForSession(ctx as never, sessionID, {
isSqliteBackend: () => false,
getMessageDir: () => messageDir,
findNearestMessageWithFields: findNearestTestMessage,
findNearestMessageWithFieldsFromSDK: async () => null,
})
// then
expect(result.model).toEqual({ providerID: "openai", modelID: "gpt-5.4" })
@@ -1,11 +1,12 @@
import { describe, expect, mock, test } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import { resolveRecentPromptContextForSession } from "./recent-model-resolver"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
describe("resolveRecentPromptContextForSession", () => {
test("uses message time.created rather than SDK array order for recent prompt context", async () => {
// given
const ctx = {
const ctx = unsafeTestValue<PluginInput>({
client: {
session: {
messages: mock(async () => ({
@@ -32,7 +33,7 @@ describe("resolveRecentPromptContextForSession", () => {
})),
},
},
} as unknown as PluginInput
})
// when
const result = await resolveRecentPromptContextForSession(ctx, "ses_123")
+20 -5
View File
@@ -11,9 +11,24 @@ type PromptContext = {
tools?: Record<string, boolean>
}
type RecentPromptContextDeps = {
isSqliteBackend: typeof isSqliteBackend
getMessageDir: typeof getMessageDir
findNearestMessageWithFields: typeof findNearestMessageWithFields
findNearestMessageWithFieldsFromSDK: typeof findNearestMessageWithFieldsFromSDK
}
const defaultDeps: RecentPromptContextDeps = {
isSqliteBackend,
getMessageDir,
findNearestMessageWithFields,
findNearestMessageWithFieldsFromSDK,
}
export async function resolveRecentPromptContextForSession(
ctx: PluginInput,
sessionID: string
sessionID: string,
deps: RecentPromptContextDeps = defaultDeps,
): Promise<PromptContext> {
try {
const messagesResp = await ctx.client.session.messages({ path: { id: sessionID } })
@@ -59,11 +74,11 @@ export async function resolveRecentPromptContextForSession(
}
let currentMessage = null
if (isSqliteBackend()) {
currentMessage = await findNearestMessageWithFieldsFromSDK(ctx.client, sessionID)
if (deps.isSqliteBackend()) {
currentMessage = await deps.findNearestMessageWithFieldsFromSDK(ctx.client, sessionID)
} else {
const messageDir = getMessageDir(sessionID)
currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
const messageDir = deps.getMessageDir(sessionID)
currentMessage = messageDir ? deps.findNearestMessageWithFields(messageDir) : null
}
const model = currentMessage?.model
const tools = normalizePromptTools(currentMessage?.tools)
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { dirname, join } from "node:path"
import { randomUUID } from "node:crypto"
import { clearBoulderState, writeBoulderState } from "../../features/boulder-state"
import { resolveActiveBoulderSession } from "./resolve-active-boulder-session"
@@ -96,4 +96,112 @@ describe("resolveActiveBoulderSession", () => {
expect(result?.progress.isComplete).toBe(false)
expect(result?.boulderState.session_ids).toContain("ses_appended")
})
test("returns complete progress when a mirrored worktree plan is complete", async () => {
// given
const mainPlanPath = join(testDirectory, ".omo", "plans", "worktree-plan.md")
const worktreeDirectory = join(tmpdir(), `resolve-active-boulder-worktree-${randomUUID()}`)
const worktreePlanPath = join(worktreeDirectory, ".omo", "plans", "worktree-plan.md")
mkdirSync(dirname(mainPlanPath), { recursive: true })
mkdirSync(dirname(worktreePlanPath), { recursive: true })
writeFileSync(mainPlanPath, "# Plan\n- [ ] Main repo task\n", "utf-8")
writeFileSync(worktreePlanPath, "# Plan\n- [x] Worktree task\n", "utf-8")
writeBoulderState(testDirectory, {
active_plan: mainPlanPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: ["ses_tracked"],
session_origins: { ses_tracked: "direct" },
plan_name: "worktree-plan",
worktree_path: worktreeDirectory,
})
try {
// when
const result = await resolveActiveBoulderSession({
client: { session: { get: async () => ({ data: {} }) } } as never,
directory: testDirectory,
sessionID: "ses_tracked",
})
// then
expect(result).not.toBeNull()
expect(result?.progress.isComplete).toBe(true)
expect(result?.progress.completed).toBe(1)
} finally {
rmSync(worktreeDirectory, { recursive: true, force: true })
}
})
test("uses work resolved by session id when works map is present", async () => {
// given
const legacyPlanPath = join(testDirectory, "legacy-plan.md")
const workAPlanPath = join(testDirectory, "work-a-plan.md")
const workBPlanPath = join(testDirectory, "work-b-plan.md")
writeFileSync(legacyPlanPath, "# Plan\n- [ ] Legacy\n", "utf-8")
writeFileSync(workAPlanPath, "# Plan\n- [ ] Work A\n", "utf-8")
writeFileSync(workBPlanPath, "# Plan\n- [x] Work B\n", "utf-8")
writeBoulderState(testDirectory, {
schema_version: 2,
active_work_id: "work-a",
active_plan: legacyPlanPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: ["ses_legacy"],
plan_name: "legacy-plan",
works: {
"work-a": {
work_id: "work-a",
active_plan: workAPlanPath,
plan_name: "work-a-plan",
started_at: "2026-01-02T10:00:00Z",
session_ids: ["ses_work_a"],
status: "active",
},
"work-b": {
work_id: "work-b",
active_plan: workBPlanPath,
plan_name: "work-b-plan",
started_at: "2026-01-02T11:00:00Z",
session_ids: ["ses_work_b"],
status: "active",
},
},
})
// when
const result = await resolveActiveBoulderSession({
client: { session: { get: async () => ({ data: {} }) } } as never,
directory: testDirectory,
sessionID: "ses_work_b",
})
// then
expect(result).not.toBeNull()
expect(result?.boulderState.active_plan).toBe(workBPlanPath)
expect(result?.progress.isComplete).toBe(true)
})
test("falls back to top-level mirror when works map is missing", async () => {
// given
const legacyPlanPath = join(testDirectory, "legacy-only-plan.md")
writeFileSync(legacyPlanPath, "# Plan\n- [ ] Task 1\n", "utf-8")
writeBoulderState(testDirectory, {
active_plan: legacyPlanPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: ["ses_legacy_only"],
plan_name: "legacy-only-plan",
})
// when
const result = await resolveActiveBoulderSession({
client: { session: { get: async () => ({ data: {} }) } } as never,
directory: testDirectory,
sessionID: "ses_legacy_only",
})
// then
expect(result).not.toBeNull()
expect(result?.boulderState.active_plan).toBe(legacyPlanPath)
expect(result?.progress.isComplete).toBe(false)
})
})
@@ -1,5 +1,11 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { getPlanProgress, readBoulderState } from "../../features/boulder-state"
import {
getPlanProgress,
getWorkForSession,
readBoulderState,
resolveBoulderPlanPath,
resolveBoulderPlanPathForWork,
} from "../../features/boulder-state"
import type { BoulderState, PlanProgress } from "../../features/boulder-state"
export async function resolveActiveBoulderSession(input: {
@@ -16,14 +22,37 @@ export async function resolveActiveBoulderSession(input: {
return null
}
if (!boulderState.session_ids.includes(input.sessionID)) {
const sessionWork = getWorkForSession(input.directory, input.sessionID)
if (!sessionWork && !boulderState.session_ids.includes(input.sessionID)) {
return null
}
const progress = getPlanProgress(boulderState.active_plan)
const nextBoulderState: BoulderState = sessionWork
? {
...boulderState,
active_plan: sessionWork.active_plan,
plan_name: sessionWork.plan_name,
status: sessionWork.status,
started_at: sessionWork.started_at,
ended_at: sessionWork.ended_at,
elapsed_ms: sessionWork.elapsed_ms,
updated_at: sessionWork.updated_at,
session_ids: [...sessionWork.session_ids],
session_origins: sessionWork.session_origins ? { ...sessionWork.session_origins } : {},
agent: sessionWork.agent,
worktree_path: sessionWork.worktree_path,
task_sessions: sessionWork.task_sessions ? { ...sessionWork.task_sessions } : {},
}
: boulderState
const progress = getPlanProgress(
sessionWork
? resolveBoulderPlanPathForWork(input.directory, sessionWork)
: resolveBoulderPlanPath(input.directory, nextBoulderState),
)
if (progress.isComplete) {
return { boulderState, progress, appendedSession: false }
return { boulderState: nextBoulderState, progress, appendedSession: false }
}
return { boulderState, progress, appendedSession: false }
return { boulderState: nextBoulderState, progress, appendedSession: false }
}
-8
View File
@@ -1,8 +0,0 @@
/**
* Cross-platform check if a path is inside .sisyphus/ directory.
* Handles both forward slashes (Unix) and backslashes (Windows).
* Uses path segment matching (not substring) to avoid false positives like "not-sisyphus/file.txt"
*/
export function isSisyphusPath(filePath: string): boolean {
return /\.sisyphus[/\\]/.test(filePath)
}
@@ -1,6 +1,8 @@
import { describe, it, expect } from "bun:test"
import {
BOULDER_COMPLETE_PROMPT,
BOULDER_CONTINUATION_PROMPT,
SINGLE_TASK_DIRECTIVE,
VERIFICATION_REMINDER,
VERIFICATION_REMINDER_GEMINI,
} from "./system-reminder-templates"
@@ -32,8 +34,8 @@ describe("BOULDER_CONTINUATION_PROMPT", () => {
expect(checkboxMarkingMatch).not.toBeNull()
expect(proceedMatch).not.toBeNull()
const checkboxPosition = checkboxMarkingMatch!.index
const proceedPosition = proceedMatch!.index
const checkboxPosition = checkboxMarkingMatch!.index ?? -1
const proceedPosition = proceedMatch!.index ?? -1
expect(checkboxPosition).toBeLessThan(proceedPosition)
})
@@ -46,8 +48,32 @@ describe("VERIFICATION_REMINDER", () => {
})
})
describe("BOULDER_COMPLETE_PROMPT", () => {
it("contains the required placeholders", () => {
expect(BOULDER_COMPLETE_PROMPT).toContain("{PLAN_NAME}")
expect(BOULDER_COMPLETE_PROMPT).toContain("{ELAPSED_HUMAN}")
expect(BOULDER_COMPLETE_PROMPT).toContain("{TASK_BREAKDOWN}")
})
})
describe("VERIFICATION_REMINDER_GEMINI", () => {
it("contains node_modules exclusion pathspec in git diff command", () => {
expect(VERIFICATION_REMINDER_GEMINI).toContain(":!node_modules")
})
})
describe("SINGLE_TASK_DIRECTIVE", () => {
it("does not contain refusal language", () => {
// given
const lowerCaseDirective = SINGLE_TASK_DIRECTIVE.toLowerCase()
// when / then
expect(lowerCaseDirective).not.toContain("refuse")
expect(SINGLE_TASK_DIRECTIVE).not.toContain("I refuse")
})
it("contains systematic execution guidance", () => {
expect(SINGLE_TASK_DIRECTIVE).toContain("EXECUTION PROTOCOL")
expect(SINGLE_TASK_DIRECTIVE).toContain("VERIFICATION IS MANDATORY")
})
})
+58 -66
View File
@@ -6,24 +6,18 @@ export const DIRECT_WORK_REMINDER = `
${createSystemDirective(SystemDirectiveTypes.DELEGATION_REQUIRED)}
You just performed direct file modifications outside \`.sisyphus/\`.
**You just edited a source file directly.**
**You are an ORCHESTRATOR, not an IMPLEMENTER.**
Did you ACTUALLY need to be the one doing that?
As an orchestrator, you should:
- **DELEGATE** implementation work to subagents via \`task\`
- **VERIFY** the work done by subagents
- **COORDINATE** multiple tasks and ensure completion
- If this was a tiny verification fix during subagent review → fine, continue.
- If this was implementation work of any size → **you violated orchestrator protocol.** Real work goes through \`task()\`. Revert the change and delegate it via \`task()\`. The subagent has the context, the tools, and the model for that work — you do not.
You should NOT:
- Write code directly (except for \`.sisyphus/\` files like plans and notepads)
- Make direct file edits outside \`.sisyphus/\`
- Implement features yourself
**Atlas does not implement. Atlas orchestrates.** Every direct edit erodes the
delegation pipeline you exist to run, and steals work the subagent is paid to do.
**If you need to make changes:**
1. Use \`task\` to delegate to an appropriate subagent
2. Provide clear instructions in the prompt
3. Verify the subagent's work after completion
Going forward: \`task()\` for implementation. Fan out in PARALLEL when independent
tasks remain — do not dispatch them one at a time.
---
`
@@ -35,10 +29,21 @@ You have an active work plan with incomplete tasks. Continue working.
RULES:
- **FIRST**: Read the plan file NOW. If the last completed task is still unchecked, mark it \`- [x]\` IMMEDIATELY before anything else
- Proceed without asking for permission
- Use the notepad at .sisyphus/notepads/{PLAN_NAME}/ to record learnings
- Use the notepad at .omo/notepads/{PLAN_NAME}/ to record learnings
- Do not stop until all tasks are complete
- If blocked, document the blocker and move to the next task`
export const BOULDER_COMPLETE_PROMPT = `<system-reminder>
BOULDER COMPLETE: plan "{PLAN_NAME}" is fully checked.
Total elapsed: {ELAPSED_HUMAN}
Per-task breakdown:
{TASK_BREAKDOWN}
Per your <boulder_completion_response> instructions, print the final ORCHESTRATION COMPLETE summary in your next turn. This nudge fires at most once.
</system-reminder>`
export const VERIFICATION_REMINDER = `**THE SUBAGENT JUST CLAIMED THIS TASK IS DONE. THEY ARE PROBABLY LYING.**
Subagents say "done" when code has errors, tests pass trivially, logic is wrong,
@@ -168,47 +173,41 @@ export const ORCHESTRATOR_DELEGATION_REQUIRED = `
${createSystemDirective(SystemDirectiveTypes.DELEGATION_REQUIRED)}
**STOP. YOU ARE VIOLATING ORCHESTRATOR PROTOCOL.**
**STOP. Atlas does not edit source code.**
You (Atlas) are attempting to directly modify a file outside \`.sisyphus/\`.
Path attempted: \`$FILE_PATH\`
**Path attempted:** $FILE_PATH
Ask yourself, honestly, before this write goes through:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. **Do you ACTUALLY need to be the one doing this?**
If a subagent could do it via \`task()\` — and the answer is almost always yes — you are stealing the subagent's work.
**THIS IS FORBIDDEN** (except for VERIFICATION purposes)
2. **Is this STRICTLY a small verification fix on subagent output?**
(≤ a couple of lines, fixing something the subagent left wrong during review.)
If yes, fine. If no — STOP this edit. Delegate it.
As an ORCHESTRATOR, you MUST:
1. **DELEGATE** all implementation work via \`task\`
2. **VERIFY** the work done by subagents (reading files is OK)
3. **COORDINATE** - you orchestrate, you don't implement
If you are about to write more than a trivial verification patch, or you are touching code no subagent has produced yet, **you are implementing**. That is forbidden.
**ALLOWED direct file operations:**
- Files inside \`.sisyphus/\` (plans, notepads, drafts)
- Reading files for verification
- Running diagnostics/tests
**Implementing yourself is the single most expensive failure mode of this role.**
Atlas is paid to ORCHESTRATE. The subagents are paid to IMPLEMENT. Every direct edit erodes the delegation pipeline you exist to run.
**FORBIDDEN direct file operations:**
- Writing/editing source code
- Creating new files outside \`.sisyphus/\`
- Any implementation work
Correct action — delegate via \`task()\`. Fan out in PARALLEL when multiple independent items remain (one message, multiple \`task()\` calls — never one-by-one):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
**IF THIS IS FOR VERIFICATION:**
Proceed if you are verifying subagent work by making a small fix.
But for any substantial changes, USE \`task\`.
**CORRECT APPROACH:**
\`\`\`
\`\`\`typescript
task(
category="...",
category="quick",
load_skills=[],
prompt="[specific single task with clear acceptance criteria]"
run_in_background=false,
prompt="[6 sections: TASK / EXPECTED OUTCOME / REQUIRED TOOLS / MUST DO / MUST NOT DO / CONTEXT]"
)
\`\`\`
DELEGATE. DON'T IMPLEMENT.
Allowed direct operations:
- \`.omo/\` files (plans, notepads)
- Reading any file (verification)
- Running commands (verification)
Everything else: DELEGATE.
---
`
@@ -217,33 +216,26 @@ export const SINGLE_TASK_DIRECTIVE = `
${createSystemDirective(SystemDirectiveTypes.SINGLE_TASK_ONLY)}
**STOP. READ THIS BEFORE PROCEEDING.**
**EXECUTION PROTOCOL**
If you were given **multiple genuinely independent goals** (unrelated tasks, parallel workstreams, separate features), you MUST:
1. **IMMEDIATELY REFUSE** this request
2. **DEMAND** the orchestrator provide a single goal
Work systematically. Each unit must be verified before proceeding.
**What counts as multiple independent tasks (REFUSE):**
- "Implement feature A. Also, add feature B."
- "Fix bug X. Then refactor module Y. Also update the docs."
- Multiple unrelated changes bundled into one request
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
**What is a single task with sequential steps (PROCEED):**
- A single goal broken into numbered steps (e.g., "Implement X by: 1. finding files, 2. adding logic, 3. writing tests")
- Multi-step context where all steps serve ONE objective
- Orchestrator-provided context explaining approach for a single deliverable
| Step | Action | Verification |
|------|--------|--------------|
| 1 | Identify first atomic unit | Smallest complete piece of work |
| 2 | Execute fully | Implement the change |
| 3 | Verify | \`lsp_diagnostics\`, tests, build |
| 4 | Report | State what's done, what remains |
| 5 | Continue | Next unit, or await if scope unclear |
**Your response if genuinely independent tasks are detected:**
> "I refuse to proceed. You provided multiple independent tasks. Each task needs full attention.
>
> PROVIDE EXACTLY ONE GOAL. One deliverable. One clear outcome.
>
> Batching unrelated tasks causes: incomplete work, missed edge cases, broken tests, wasted context."
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
**WARNING TO ORCHESTRATOR:**
- Bundling unrelated tasks RUINS deliverables
- Each independent goal needs FULL attention and PROPER verification
- Batch delegation of separate concerns = sloppy work = rework = wasted tokens
**VERIFICATION IS MANDATORY.** No skipping. No batching completions.
**REFUSE genuinely multi-task requests. ALLOW single-goal multi-step workflows.**
**IF SCOPE SEEMS BROAD:**
Complete the first logical unit. Report progress. Await further instruction if needed.
**REMEMBER:** Prometheus already decomposed the work. Execute what you receive.
`
@@ -8,6 +8,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
import type { Project } from "@opencode-ai/sdk"
import { readBoulderState, writeBoulderState } from "../../features/boulder-state"
import { createToolExecuteBeforeHandler } from "./tool-execute-before"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
const isCallerOrchestratorMock = mock(async () => true)
const collectGitDiffStatsMock = mock(() => ({
@@ -15,15 +16,7 @@ const collectGitDiffStatsMock = mock(() => ({
insertions: 0,
deletions: 0,
}))
mock.module("../../shared/session-utils", () => ({
isCallerOrchestrator: isCallerOrchestratorMock,
}))
mock.module("../../shared/git-worktree", () => ({
collectGitDiffStats: collectGitDiffStatsMock,
formatFileChanges: mock(() => "No file changes"),
}))
const formatFileChangesMock = mock(() => "No file changes")
afterAll(() => { mock.restore() })
@@ -49,6 +42,7 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
isCallerOrchestratorMock.mockClear()
collectGitDiffStatsMock.mockClear()
formatFileChangesMock.mockClear()
})
afterEach(() => {
@@ -80,11 +74,11 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
function createHandler(parentSessionIDs?: Record<string, string | undefined>) {
const project = createProject()
const client = {
const client = unsafeTestValue<PluginInput["client"]>({
session: {
get: async (input: SessionGetInput) => createSessionGetResult(parentSessionIDs?.[input.path.id]),
},
} as unknown as PluginInput["client"]
})
if (parentSessionIDs) {
spyOn(client.session, "get").mockImplementation((input) => Promise.resolve(
@@ -107,6 +101,9 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
pendingTaskRefs: new Map(),
autoCommit: true,
getState: () => ({ promptFailureCount: 0 }),
isCallerOrchestrator: isCallerOrchestratorMock,
collectGitDiffStats: collectGitDiffStatsMock as never,
formatFileChanges: formatFileChangesMock as never,
})
}
@@ -141,11 +138,11 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
const childSessionID = "ses_child123"
const planPath = join(testDirectory, "background-launch-plan.md")
const project = createProject()
const client = {
const client = unsafeTestValue<PluginInput["client"]>({
session: {
get: async () => createSessionGetResult(undefined),
},
} as unknown as PluginInput["client"]
})
spyOn(client.session, "get").mockImplementation((input) => Promise.resolve(
createSessionGetResult(input?.path?.id === childSessionID ? sessionID : undefined),
@@ -174,13 +171,21 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
serverUrl: new URL("https://example.com"),
$: Bun.$,
} satisfies PluginInput
const beforeHandler = createToolExecuteBeforeHandler({ ctx, pendingFilePaths, pendingTaskRefs })
const beforeHandler = createToolExecuteBeforeHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
isCallerOrchestrator: isCallerOrchestratorMock,
})
const afterHandler = createToolExecuteAfterHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
autoCommit: true,
getState: () => ({ promptFailureCount: 0 }),
isCallerOrchestrator: isCallerOrchestratorMock,
collectGitDiffStats: collectGitDiffStatsMock as never,
formatFileChanges: formatFileChangesMock as never,
})
await beforeHandler(
@@ -215,11 +220,11 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
const childSessionID = "ses_child_lookup_failure"
const planPath = join(testDirectory, "background-launch-plan.md")
const project = createProject()
const client = {
const client = unsafeTestValue<PluginInput["client"]>({
session: {
get: async () => createSessionGetResult(undefined),
},
} as unknown as PluginInput["client"]
})
spyOn(client.session, "get").mockImplementation((input) => {
if (input?.path?.id === childSessionID) {
@@ -251,13 +256,21 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
serverUrl: new URL("https://example.com"),
$: Bun.$,
} satisfies PluginInput
const beforeHandler = createToolExecuteBeforeHandler({ ctx, pendingFilePaths, pendingTaskRefs })
const beforeHandler = createToolExecuteBeforeHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
isCallerOrchestrator: isCallerOrchestratorMock,
})
const afterHandler = createToolExecuteAfterHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
autoCommit: true,
getState: () => ({ promptFailureCount: 0 }),
isCallerOrchestrator: isCallerOrchestratorMock,
collectGitDiffStats: collectGitDiffStatsMock as never,
formatFileChanges: formatFileChangesMock as never,
})
await beforeHandler(
@@ -288,11 +301,11 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
const childSessionID = "ses_outside_lineage"
const planPath = join(testDirectory, "background-launch-plan.md")
const project = createProject()
const client = {
const client = unsafeTestValue<PluginInput["client"]>({
session: {
get: async () => createSessionGetResult(undefined),
},
} as unknown as PluginInput["client"]
})
spyOn(client.session, "get").mockImplementation((input) => Promise.resolve(
createSessionGetResult(input?.path?.id === childSessionID ? "ses_unrelated_parent" : undefined),
@@ -321,13 +334,21 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
serverUrl: new URL("https://example.com"),
$: Bun.$,
} satisfies PluginInput
const beforeHandler = createToolExecuteBeforeHandler({ ctx, pendingFilePaths, pendingTaskRefs })
const beforeHandler = createToolExecuteBeforeHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
isCallerOrchestrator: isCallerOrchestratorMock,
})
const afterHandler = createToolExecuteAfterHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
autoCommit: true,
getState: () => ({ promptFailureCount: 0 }),
isCallerOrchestrator: isCallerOrchestratorMock,
collectGitDiffStats: collectGitDiffStatsMock as never,
formatFileChanges: formatFileChangesMock as never,
})
await beforeHandler(
@@ -358,11 +379,11 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
const childSessionID = "ses_unrelated_child"
const planPath = join(testDirectory, "background-launch-plan.md")
const project = createProject()
const client = {
const client = unsafeTestValue<PluginInput["client"]>({
session: {
get: async () => createSessionGetResult(undefined),
},
} as unknown as PluginInput["client"]
})
spyOn(client.session, "get").mockImplementation((input) => Promise.resolve(
createSessionGetResult(input?.path?.id === childSessionID ? sessionID : undefined),
@@ -392,13 +413,21 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
serverUrl: new URL("https://example.com"),
$: Bun.$,
} satisfies PluginInput
const beforeHandler = createToolExecuteBeforeHandler({ ctx, pendingFilePaths, pendingTaskRefs })
const beforeHandler = createToolExecuteBeforeHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
isCallerOrchestrator: isCallerOrchestratorMock,
})
const afterHandler = createToolExecuteAfterHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
autoCommit: true,
getState: () => ({ promptFailureCount: 0 }),
isCallerOrchestrator: isCallerOrchestratorMock,
collectGitDiffStats: collectGitDiffStatsMock as never,
formatFileChanges: formatFileChangesMock as never,
})
await beforeHandler(
@@ -424,6 +453,102 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
expect(readBoulderState(testDirectory)?.session_ids).not.toContain(sessionID)
expect(readBoulderState(testDirectory)?.session_ids).not.toContain(childSessionID)
})
it("#then it should append launched child to the session-resolved work", async () => {
const parentSessionID = "ses_parent_for_work"
const childSessionID = "ses_child_for_work"
const planPathA = join(testDirectory, "background-launch-work-a.md")
const planPathB = join(testDirectory, "background-launch-work-b.md")
const project = createProject()
const client = unsafeTestValue<PluginInput["client"]>({
session: {
get: async () => createSessionGetResult(undefined),
},
})
spyOn(client.session, "get").mockImplementation((input) => Promise.resolve(
createSessionGetResult(input?.path?.id === childSessionID ? parentSessionID : undefined),
) as never)
writeFileSync(planPathA, "# Plan\n\n## TODOs\n- [ ] 1. Work A\n")
writeFileSync(planPathB, "# Plan\n\n## TODOs\n- [ ] 1. Work B\n")
writeBoulderState(testDirectory, {
schema_version: 2,
active_work_id: "work-a",
active_plan: planPathA,
started_at: "2026-01-02T10:00:00Z",
session_ids: ["ses_unrelated_active"],
plan_name: "background-launch-work-a",
works: {
"work-a": {
work_id: "work-a",
active_plan: planPathA,
plan_name: "background-launch-work-a",
started_at: "2026-01-02T10:00:00Z",
session_ids: ["ses_unrelated_active"],
status: "active",
},
"work-b": {
work_id: "work-b",
active_plan: planPathB,
plan_name: "background-launch-work-b",
started_at: "2026-01-02T10:05:00Z",
session_ids: [parentSessionID],
status: "active",
},
},
})
const pendingFilePaths = new Map<string, string>()
const pendingTaskRefs = new Map()
const ctx = {
client,
project,
directory: testDirectory,
worktree: testDirectory,
serverUrl: new URL("https://example.com"),
$: Bun.$,
} satisfies PluginInput
const beforeHandler = createToolExecuteBeforeHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
isCallerOrchestrator: isCallerOrchestratorMock,
})
const afterHandler = createToolExecuteAfterHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
autoCommit: true,
getState: () => ({ promptFailureCount: 0 }),
isCallerOrchestrator: isCallerOrchestratorMock,
collectGitDiffStats: collectGitDiffStatsMock as never,
formatFileChanges: formatFileChangesMock as never,
})
await beforeHandler(
{ tool: "task", sessionID: parentSessionID, callID: "call-bg-work" },
{ args: { prompt: "Work B" } },
)
await afterHandler(
{ tool: "task", sessionID: parentSessionID, callID: "call-bg-work" },
{
title: "Sisyphus Task",
output: "Background task launched.\n\nBackground Task ID: bg_work\n\n<task_metadata>\nsession_id: ses_child_for_work\n</task_metadata>",
metadata: {
sessionId: childSessionID,
agent: "sisyphus-junior",
category: "deep",
},
},
)
const boulderState = readBoulderState(testDirectory)
expect(boulderState?.works?.["work-b"]?.session_ids).toContain(childSessionID)
expect(boulderState?.works?.["work-a"]?.session_ids).not.toContain(childSessionID)
})
})
})
})
@@ -0,0 +1,432 @@
/// <reference types="bun-types" />
import { afterAll, afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import type { PluginInput } from "@opencode-ai/plugin"
import type { Project } from "@opencode-ai/sdk"
import { readBoulderState, writeBoulderState } from "../../features/boulder-state"
import { createToolExecuteBeforeHandler } from "./tool-execute-before"
const isCallerOrchestratorMock = mock(async () => true)
const collectGitDiffStatsMock = mock(() => ({
filesChanged: 0,
insertions: 0,
deletions: 0,
}))
const formatFileChangesMock = mock(() => "No file changes")
afterAll(() => { mock.restore() })
const { createToolExecuteAfterHandler } = await import("./tool-execute-after")
type SessionGetInput = { path: { id: string } }
type SessionGetResult = {
data: { parentID: string | undefined }
error?: undefined
request: Request
response: Response
}
describe("createToolExecuteAfterHandler task timers", () => {
let testDirectory = ""
beforeEach(() => {
testDirectory = join(tmpdir(), `atlas-task-timers-${crypto.randomUUID()}`)
if (!existsSync(testDirectory)) {
mkdirSync(testDirectory, { recursive: true })
}
isCallerOrchestratorMock.mockClear()
collectGitDiffStatsMock.mockClear()
formatFileChangesMock.mockClear()
})
afterEach(() => {
if (testDirectory && existsSync(testDirectory)) {
rmSync(testDirectory, { recursive: true, force: true })
}
})
function createProject(): Project {
return {
id: "project-1",
worktree: testDirectory,
time: { created: Date.now() },
}
}
function createSessionGetResult(parentID: string | undefined): SessionGetResult {
return {
data: { parentID },
error: undefined,
request: new Request("https://example.com/session"),
response: new Response(null, { status: 200 }),
} as SessionGetResult
}
function createHandlers(parentSessionIDs?: Record<string, string | undefined>) {
const project = createProject()
const client = {
session: {
get: async (input: SessionGetInput) => createSessionGetResult(parentSessionIDs?.[input.path.id]),
},
} as PluginInput["client"]
if (parentSessionIDs) {
spyOn(client.session, "get").mockImplementation((input) => Promise.resolve(
createSessionGetResult(parentSessionIDs[input?.path?.id ?? ""]),
) as never)
}
const pendingFilePaths = new Map<string, string>()
const pendingTaskRefs = new Map()
const pendingPlanSnapshots = new Map<string, string>()
const ctx = {
client,
project,
directory: testDirectory,
worktree: testDirectory,
serverUrl: new URL("https://example.com"),
$: Bun.$,
} satisfies PluginInput
return {
beforeHandler: createToolExecuteBeforeHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
pendingPlanSnapshots,
isCallerOrchestrator: isCallerOrchestratorMock,
}),
afterHandler: createToolExecuteAfterHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
pendingPlanSnapshots,
autoCommit: true,
getState: () => ({ promptFailureCount: 0 }),
isCallerOrchestrator: isCallerOrchestratorMock,
collectGitDiffStats: collectGitDiffStatsMock as never,
formatFileChanges: formatFileChangesMock as never,
}),
}
}
it("starts task timer for todo:1 when delegated task session is tracked", async () => {
// given
const parentSessionID = "ses_parent"
const childSessionID = "ses_child"
const planPath = join(testDirectory, "task-timer-plan.md")
writeFileSync(planPath, "# Plan\n\n## TODOs\n- [ ] 1. Implement auth flow\n", "utf-8")
writeBoulderState(testDirectory, {
schema_version: 2,
active_work_id: "work-1",
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [parentSessionID],
plan_name: "task-timer-plan",
works: {
"work-1": {
work_id: "work-1",
active_plan: planPath,
plan_name: "task-timer-plan",
started_at: "2026-01-02T10:00:00Z",
session_ids: [parentSessionID],
status: "active",
},
},
})
const { beforeHandler, afterHandler } = createHandlers({
[childSessionID]: parentSessionID,
})
await beforeHandler(
{ tool: "task", sessionID: parentSessionID, callID: "call-task-timer-1" },
{ args: { prompt: "Implement auth flow" } },
)
// when
await afterHandler(
{ tool: "task", sessionID: parentSessionID, callID: "call-task-timer-1" },
{
title: "Sisyphus Task",
output: "Task completed\n<task_metadata>\nsession_id: ses_child\n</task_metadata>",
metadata: {
sessionId: childSessionID,
agent: "sisyphus-junior",
category: "deep",
},
},
)
// then
const taskSession = readBoulderState(testDirectory)?.works?.["work-1"]?.task_sessions?.["todo:1"]
expect(taskSession).toBeDefined()
expect(taskSession?.started_at).toBeString()
expect(taskSession?.status).toBe("running")
expect(taskSession?.session_id).toBe(childSessionID)
})
it("ends task timer when todo:1 checkbox transitions to checked", async () => {
// given
const parentSessionID = "ses_parent_2"
const childSessionID = "ses_child_2"
const planPath = join(testDirectory, "task-timer-complete-plan.md")
writeFileSync(planPath, "# Plan\n\n## TODOs\n- [ ] 1. Implement auth flow\n", "utf-8")
writeBoulderState(testDirectory, {
schema_version: 2,
active_work_id: "work-1",
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [parentSessionID],
plan_name: "task-timer-complete-plan",
works: {
"work-1": {
work_id: "work-1",
active_plan: planPath,
plan_name: "task-timer-complete-plan",
started_at: "2026-01-02T10:00:00Z",
session_ids: [parentSessionID],
status: "active",
},
},
})
const { beforeHandler, afterHandler } = createHandlers({
[childSessionID]: parentSessionID,
})
await beforeHandler(
{ tool: "task", sessionID: parentSessionID, callID: "call-task-timer-2" },
{ args: { prompt: "Implement auth flow" } },
)
writeFileSync(planPath, "# Plan\n\n## TODOs\n- [x] 1. Implement auth flow\n", "utf-8")
// when
await afterHandler(
{ tool: "task", sessionID: parentSessionID, callID: "call-task-timer-2" },
{
title: "Sisyphus Task",
output: "Task completed\n<task_metadata>\nsession_id: ses_child_2\n</task_metadata>",
metadata: {
sessionId: childSessionID,
agent: "sisyphus-junior",
category: "deep",
},
},
)
// then
const taskSession = readBoulderState(testDirectory)?.works?.["work-1"]?.task_sessions?.["todo:1"]
expect(taskSession).toBeDefined()
expect(taskSession?.ended_at).toBeString()
expect(taskSession?.status).toBe("completed")
expect(typeof taskSession?.elapsed_ms).toBe("number")
})
it("ends task timer when plan checkbox flips to checked via edit tool", async () => {
// given
const parentSessionID = "ses_parent_3"
const planDirectory = join(testDirectory, ".omo", "plans")
mkdirSync(planDirectory, { recursive: true })
const planPath = join(planDirectory, "task-timer-edit-plan.md")
writeFileSync(planPath, "# Plan\n\n## TODOs\n- [ ] 1. Implement auth flow\n", "utf-8")
writeBoulderState(testDirectory, {
schema_version: 2,
active_work_id: "work-1",
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [parentSessionID],
plan_name: "task-timer-edit-plan",
task_sessions: {
"todo:1": {
task_key: "todo:1",
task_label: "1",
task_title: "Implement auth flow",
session_id: "ses_child_3",
started_at: "2026-01-02T10:00:00Z",
status: "running",
updated_at: "2026-01-02T10:00:00Z",
},
},
works: {
"work-1": {
work_id: "work-1",
active_plan: planPath,
plan_name: "task-timer-edit-plan",
started_at: "2026-01-02T10:00:00Z",
session_ids: [parentSessionID],
status: "active",
task_sessions: {},
},
},
})
const { beforeHandler, afterHandler } = createHandlers()
await beforeHandler(
{ tool: "edit", sessionID: parentSessionID, callID: "call-task-timer-edit-1" },
{ args: { filePath: planPath, oldString: "- [ ] 1. Implement auth flow", newString: "- [x] 1. Implement auth flow" } },
)
writeFileSync(planPath, "# Plan\n\n## TODOs\n- [x] 1. Implement auth flow\n", "utf-8")
// when
await afterHandler(
{ tool: "edit", sessionID: parentSessionID, callID: "call-task-timer-edit-1" },
{
title: "Edit",
output: "Updated file",
metadata: {
filePath: planPath,
},
},
)
// then
const taskSession = readBoulderState(testDirectory)?.works?.["work-1"]?.task_sessions?.["todo:1"]
expect(taskSession).toBeDefined()
expect(taskSession?.ended_at).toBeString()
expect(taskSession?.status).toBe("completed")
expect(typeof taskSession?.elapsed_ms).toBe("number")
expect((taskSession?.elapsed_ms ?? 0) > 0).toBe(true)
})
it("tracks parallel delegated tasks by task label from TASK section", async () => {
// given
const parentSessionID = "ses_parent_parallel"
const planPath = join(testDirectory, "task-timer-parallel-plan.md")
writeFileSync(
planPath,
"# Plan\n\n## TODOs\n- [ ] 1. First task\n- [ ] 2. Add tests\n- [ ] 3. Write docs\n",
"utf-8",
)
writeBoulderState(testDirectory, {
schema_version: 2,
active_work_id: "work-1",
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [parentSessionID],
plan_name: "task-timer-parallel-plan",
works: {
"work-1": {
work_id: "work-1",
active_plan: planPath,
plan_name: "task-timer-parallel-plan",
started_at: "2026-01-02T10:00:00Z",
session_ids: [parentSessionID],
status: "active",
},
},
})
const { beforeHandler, afterHandler } = createHandlers({
ses_child_parallel_2: parentSessionID,
ses_child_parallel_3: parentSessionID,
})
await beforeHandler(
{ tool: "task", sessionID: parentSessionID, callID: "call-task-parallel-2" },
{
args: {
prompt: "## 1. TASK\n- [ ] 2. Add tests\n\n## 2. CONTEXT\n...",
},
},
)
await beforeHandler(
{ tool: "task", sessionID: parentSessionID, callID: "call-task-parallel-3" },
{
args: {
prompt: "## 1. TASK\n- [ ] 3. Write docs\n\n## 2. CONTEXT\n...",
},
},
)
// when
await afterHandler(
{ tool: "task", sessionID: parentSessionID, callID: "call-task-parallel-2" },
{
title: "Sisyphus Task",
output: "Task completed\n<task_metadata>\nsession_id: ses_child_parallel_2\n</task_metadata>",
metadata: {
sessionId: "ses_child_parallel_2",
agent: "sisyphus-junior",
category: "deep",
},
},
)
await afterHandler(
{ tool: "task", sessionID: parentSessionID, callID: "call-task-parallel-3" },
{
title: "Sisyphus Task",
output: "Task completed\n<task_metadata>\nsession_id: ses_child_parallel_3\n</task_metadata>",
metadata: {
sessionId: "ses_child_parallel_3",
agent: "sisyphus-junior",
category: "deep",
},
},
)
// then
const taskSessions = readBoulderState(testDirectory)?.works?.["work-1"]?.task_sessions
expect(taskSessions?.["todo:2"]?.task_key).toBe("todo:2")
expect(taskSessions?.["todo:3"]?.task_key).toBe("todo:3")
expect(taskSessions?.["todo:1"]).toBeUndefined()
})
it("falls back to current top-level task when TASK section label is missing", async () => {
// given
const parentSessionID = "ses_parent_fallback"
const childSessionID = "ses_child_fallback"
const planPath = join(testDirectory, "task-timer-fallback-plan.md")
writeFileSync(planPath, "# Plan\n\n## TODOs\n- [ ] 1. First task\n", "utf-8")
writeBoulderState(testDirectory, {
schema_version: 2,
active_work_id: "work-1",
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [parentSessionID],
plan_name: "task-timer-fallback-plan",
works: {
"work-1": {
work_id: "work-1",
active_plan: planPath,
plan_name: "task-timer-fallback-plan",
started_at: "2026-01-02T10:00:00Z",
session_ids: [parentSessionID],
status: "active",
},
},
})
const { beforeHandler, afterHandler } = createHandlers({
[childSessionID]: parentSessionID,
})
await beforeHandler(
{ tool: "task", sessionID: parentSessionID, callID: "call-task-fallback-1" },
{
args: {
prompt: "No structured header in this prompt",
},
},
)
// when
await afterHandler(
{ tool: "task", sessionID: parentSessionID, callID: "call-task-fallback-1" },
{
title: "Sisyphus Task",
output: "Task completed\n<task_metadata>\nsession_id: ses_child_fallback\n</task_metadata>",
metadata: {
sessionId: childSessionID,
agent: "sisyphus-junior",
category: "deep",
},
},
)
// then
const taskSessions = readBoulderState(testDirectory)?.works?.["work-1"]?.task_sessions
expect(taskSessions?.["todo:1"]?.task_key).toBe("todo:1")
})
})
+185 -25
View File
@@ -1,11 +1,17 @@
import type { PluginInput } from "@opencode-ai/plugin"
import {
appendSessionId,
endTaskTimer,
getWorkForSession,
getPlanProgress,
getTaskSessionState,
readBoulderState,
resolveBoulderPlanPath,
resolveBoulderPlanPathForWork,
startTaskTimer,
upsertTaskSessionState,
} from "../../features/boulder-state"
import { existsSync, readFileSync } from "node:fs"
import { resolve } from "node:path"
import { log } from "../../shared/logger"
import { isCallerOrchestrator } from "../../shared/session-utils"
import { syncBackgroundLaunchSessionTracking } from "./background-launch-session-tracking"
@@ -13,7 +19,7 @@ import { collectGitDiffStats, formatFileChanges } from "../../shared/git-worktre
import { shouldPauseForFinalWaveApproval } from "./final-wave-approval-gate"
import { HOOK_NAME } from "./hook-name"
import { DIRECT_WORK_REMINDER } from "./system-reminder-templates"
import { isSisyphusPath } from "./sisyphus-path"
import { isOmoPath } from "./omo-path"
import { resolvePreferredSessionId, resolveTaskContext } from "./task-context"
import { extractSessionIdFromMetadata, extractSessionIdFromOutput, validateSubagentSessionId } from "./subagent-session-id"
import {
@@ -26,33 +32,150 @@ import { isWriteOrEditToolName } from "./write-edit-tool-policy"
import type { PendingTaskRef, SessionState } from "./types"
import type { ToolExecuteAfterInput, ToolExecuteAfterOutput } from "./types"
function isTrackedTaskChecked(planPath: string, taskKey: string): boolean {
if (!existsSync(planPath)) {
return false
}
const [section, label] = taskKey.split(":")
if (!section || !label) {
return false
}
const escapedLabel = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const matcher = section === "todo"
? new RegExp(`^\\s*[-*]\\s*\\[[xX]\\]\\s*${escapedLabel}\\.\\s+`, "m")
: section === "final-wave"
? new RegExp(`^\\s*[-*]\\s*\\[[xX]\\]\\s*${escapedLabel.toUpperCase()}\\.\\s+`, "m")
: null
if (!matcher) {
return false
}
try {
const content = readFileSync(planPath, "utf-8")
return matcher.test(content)
} catch {
return false
}
}
const TODO_HEADING_PATTERN = /^##\s+TODOs\b/i
const FINAL_VERIFICATION_HEADING_PATTERN = /^##\s+Final Verification Wave\b/i
const SECOND_LEVEL_HEADING_PATTERN = /^##\s+/
const CHECKED_CHECKBOX_PATTERN = /^(\s*)[-*]\s*\[[xX]\]\s*(.+)$/
const TODO_TASK_PATTERN = /^(\d+)\.\s+(.+)$/
const FINAL_WAVE_TASK_PATTERN = /^(F\d+)\.\s+(.+)$/i
function parseCheckedTopLevelTaskKeys(planContent: string): Set<string> {
const checkedKeys = new Set<string>()
const lines = planContent.split(/\r?\n/)
let section: "todo" | "final-wave" | "other" = "other"
for (const line of lines) {
if (SECOND_LEVEL_HEADING_PATTERN.test(line)) {
section = TODO_HEADING_PATTERN.test(line)
? "todo"
: FINAL_VERIFICATION_HEADING_PATTERN.test(line)
? "final-wave"
: "other"
continue
}
if (section !== "todo" && section !== "final-wave") {
continue
}
const checkedMatch = line.match(CHECKED_CHECKBOX_PATTERN)
if (!checkedMatch || checkedMatch[1].length > 0) {
continue
}
const taskBody = checkedMatch[2].trim()
if (section === "todo") {
const taskMatch = taskBody.match(TODO_TASK_PATTERN)
if (taskMatch?.[1]) {
checkedKeys.add(`todo:${taskMatch[1]}`)
}
continue
}
const taskMatch = taskBody.match(FINAL_WAVE_TASK_PATTERN)
if (taskMatch?.[1]) {
checkedKeys.add(`final-wave:${taskMatch[1].toLowerCase()}`)
}
}
return checkedKeys
}
function readCheckedTaskKeysFromPlan(planPath: string): Set<string> {
if (!existsSync(planPath)) {
return new Set<string>()
}
try {
return parseCheckedTopLevelTaskKeys(readFileSync(planPath, "utf-8"))
} catch {
return new Set<string>()
}
}
export function createToolExecuteAfterHandler(input: {
ctx: PluginInput
pendingFilePaths: Map<string, string>
pendingTaskRefs: Map<string, PendingTaskRef>
pendingPlanSnapshots?: Map<string, string>
autoCommit: boolean
getState: (sessionID: string) => SessionState
}): (toolInput: ToolExecuteAfterInput, toolOutput: ToolExecuteAfterOutput) => Promise<void> {
const { ctx, pendingFilePaths, pendingTaskRefs, autoCommit, getState } = input
isCallerOrchestrator?: (sessionID: string | undefined) => Promise<boolean>
collectGitDiffStats?: typeof collectGitDiffStats
formatFileChanges?: typeof formatFileChanges
}): (toolInput: ToolExecuteAfterInput, toolOutput: ToolExecuteAfterOutput | undefined) => Promise<void> {
const { ctx, pendingFilePaths, pendingTaskRefs, pendingPlanSnapshots, autoCommit, getState } = input
const resolveIsCallerOrchestrator = input.isCallerOrchestrator ?? ((sessionID) => isCallerOrchestrator(sessionID, ctx.client))
const collectGitDiffStatsImpl = input.collectGitDiffStats ?? collectGitDiffStats
const formatFileChangesImpl = input.formatFileChanges ?? formatFileChanges
return async (toolInput, toolOutput): Promise<void> => {
// Guard against undefined output (e.g., from /review command - see issue #1035)
if (!toolOutput) {
return
}
if (!(await isCallerOrchestrator(toolInput.sessionID, ctx.client))) {
if (!(await resolveIsCallerOrchestrator(toolInput.sessionID))) {
return
}
if (isWriteOrEditToolName(toolInput.tool)) {
let filePath = toolInput.callID ? pendingFilePaths.get(toolInput.callID) : undefined
const planSnapshot = toolInput.callID && pendingPlanSnapshots
? pendingPlanSnapshots.get(toolInput.callID)
: undefined
if (toolInput.callID) {
pendingFilePaths.delete(toolInput.callID)
pendingPlanSnapshots?.delete(toolInput.callID)
}
if (!filePath) {
filePath = toolOutput.metadata?.filePath as string | undefined
}
if (filePath && !isSisyphusPath(filePath)) {
if (filePath && toolInput.sessionID) {
const sessionWork = getWorkForSession(ctx.directory, toolInput.sessionID)
if (sessionWork) {
const planPath = resolveBoulderPlanPathForWork(ctx.directory, sessionWork)
if (resolve(filePath) === resolve(planPath) && planSnapshot !== undefined) {
const beforeCheckedKeys = parseCheckedTopLevelTaskKeys(planSnapshot)
const afterCheckedKeys = readCheckedTaskKeysFromPlan(planPath)
for (const taskKey of afterCheckedKeys) {
if (!beforeCheckedKeys.has(taskKey)) {
endTaskTimer(ctx.directory, sessionWork.work_id, taskKey)
}
}
}
}
}
if (filePath && !isOmoPath(filePath)) {
toolOutput.output = (toolOutput.output || "") + DIRECT_WORK_REMINDER
log(`[${HOOK_NAME}] Direct work reminder appended`, {
sessionID: toolInput.sessionID,
@@ -93,23 +216,46 @@ export function createToolExecuteAfterHandler(input: {
if (toolOutput.output && typeof toolOutput.output === "string") {
const worktreePath = boulderState?.worktree_path?.trim()
const verificationDirectory = worktreePath ? worktreePath : ctx.directory
const gitStats = collectGitDiffStats(verificationDirectory)
const fileChanges = formatFileChanges(gitStats)
const gitStats = collectGitDiffStatsImpl(verificationDirectory)
const fileChanges = formatFileChangesImpl(gitStats)
const extractedSessionId = metadataSessionId ?? extractSessionIdFromOutput(toolOutput.output)
if (boulderState) {
const progress = getPlanProgress(boulderState.active_plan)
const sessionWork = toolInput.sessionID
? getWorkForSession(ctx.directory, toolInput.sessionID)
: null
const planPath = sessionWork
? resolveBoulderPlanPathForWork(ctx.directory, sessionWork)
: resolveBoulderPlanPath(ctx.directory, boulderState)
const workScopedBoulderState = sessionWork
? {
...boulderState,
active_plan: sessionWork.active_plan,
plan_name: sessionWork.plan_name,
status: sessionWork.status,
started_at: sessionWork.started_at,
ended_at: sessionWork.ended_at,
elapsed_ms: sessionWork.elapsed_ms,
updated_at: sessionWork.updated_at,
session_ids: [...sessionWork.session_ids],
session_origins: sessionWork.session_origins ? { ...sessionWork.session_origins } : {},
agent: sessionWork.agent,
worktree_path: sessionWork.worktree_path,
task_sessions: sessionWork.task_sessions ? { ...sessionWork.task_sessions } : {},
}
: boulderState
const progress = getPlanProgress(planPath)
const {
currentTask,
shouldSkipTaskSessionUpdate,
shouldIgnoreCurrentSessionId,
} = resolveTaskContext(pendingTaskRef, boulderState.active_plan)
} = resolveTaskContext(pendingTaskRef, planPath)
const trackedTaskSession = currentTask
? getTaskSessionState(ctx.directory, currentTask.key)
: null
const sessionState = toolInput.sessionID ? getState(toolInput.sessionID) : undefined
const lineageSessionIDs = boulderState.session_ids
const lineageSessionIDs = sessionWork?.session_ids ?? boulderState.session_ids
const subagentSessionId = await validateSubagentSessionId({
client: ctx.client,
sessionID: extractedSessionId,
@@ -117,14 +263,28 @@ export function createToolExecuteAfterHandler(input: {
})
if (currentTask && subagentSessionId && !shouldSkipTaskSessionUpdate) {
upsertTaskSessionState(ctx.directory, {
taskKey: currentTask.key,
taskLabel: currentTask.label,
taskTitle: currentTask.title,
sessionId: subagentSessionId,
agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined,
category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined,
})
if (sessionWork) {
startTaskTimer(ctx.directory, sessionWork.work_id, {
taskKey: currentTask.key,
taskLabel: currentTask.label,
taskTitle: currentTask.title,
sessionId: subagentSessionId,
agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined,
category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined,
})
if (isTrackedTaskChecked(planPath, currentTask.key)) {
endTaskTimer(ctx.directory, sessionWork.work_id, currentTask.key)
}
} else {
upsertTaskSessionState(ctx.directory, {
taskKey: currentTask.key,
taskLabel: currentTask.label,
taskTitle: currentTask.title,
sessionId: subagentSessionId,
agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined,
category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined,
})
}
}
const preferredSessionId = resolvePreferredSessionId(
@@ -136,7 +296,7 @@ export function createToolExecuteAfterHandler(input: {
const originalResponse = toolOutput.output
const shouldPauseForApproval = sessionState
? shouldPauseForFinalWaveApproval({
planPath: boulderState.active_plan,
planPath,
taskOutput: originalResponse,
sessionState,
})
@@ -152,11 +312,11 @@ export function createToolExecuteAfterHandler(input: {
}
const leadReminder = shouldPauseForApproval
? buildFinalWaveApprovalReminder(boulderState.plan_name, progress, preferredSessionId)
: buildCompletionGate(boulderState.plan_name, preferredSessionId)
? buildFinalWaveApprovalReminder(workScopedBoulderState.plan_name, progress, preferredSessionId)
: buildCompletionGate(workScopedBoulderState.plan_name, preferredSessionId)
const followupReminder = shouldPauseForApproval
? null
: buildOrchestratorReminder(boulderState.plan_name, progress, preferredSessionId, autoCommit, false)
: buildOrchestratorReminder(workScopedBoulderState.plan_name, progress, preferredSessionId, autoCommit, false)
toolOutput.output = `
<system-reminder>
@@ -178,8 +338,8 @@ ${
? ""
: `<system-reminder>\n${followupReminder}\n</system-reminder>`
}`
log(`[${HOOK_NAME}] Output transformed for orchestrator mode (boulder)`, {
plan: boulderState.plan_name,
log(`[${HOOK_NAME}] Output transformed for orchestrator mode (boulder)`, {
plan: workScopedBoulderState.plan_name,
progress: `${progress.completed}/${progress.total}`,
fileCount: gitStats.length,
preferredSessionId,
+105 -18
View File
@@ -2,29 +2,77 @@ 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 { readBoulderState, readCurrentTopLevelTask } from "../../features/boulder-state"
import { existsSync, readFileSync } from "node:fs"
import { resolve } from "node:path"
import { getWorkForSession, readBoulderState, readCurrentTopLevelTask, resolveBoulderPlanPath, resolveBoulderPlanPathForWork } from "../../features/boulder-state"
import { HOOK_NAME } from "./hook-name"
import { ORCHESTRATOR_DELEGATION_REQUIRED, SINGLE_TASK_DIRECTIVE } from "./system-reminder-templates"
import { isSisyphusPath } from "./sisyphus-path"
import { isOmoPath } from "./omo-path"
import type { PendingTaskRef, TrackedTopLevelTaskRef } from "./types"
import { isWriteOrEditToolName } from "./write-edit-tool-policy"
const TASK_SECTION_HEADER_PATTERN = /^##\s*1\.\s*TASK\s*$/i
const TODO_TASK_LINE_PATTERN = /^(?:[-*]\s*\[\s*\]\s*)?(\d+)\.\s+(.+)$/
const FINAL_WAVE_TASK_LINE_PATTERN = /^(?:[-*]\s*\[\s*\]\s*)?(F\d+)\.\s+(.+)$/i
function parseTrackedTaskFromPrompt(prompt: string): TrackedTopLevelTaskRef | null {
const lines = prompt.split(/\r?\n/)
const taskHeaderIndex = lines.findIndex((line) => TASK_SECTION_HEADER_PATTERN.test(line.trim()))
if (taskHeaderIndex < 0) {
return null
}
const startIndex = taskHeaderIndex + 1
const endIndex = Math.min(lines.length, startIndex + 5)
for (let index = startIndex; index < endIndex; index += 1) {
const candidate = lines[index]?.trim()
if (!candidate) {
continue
}
const finalWaveMatch = candidate.match(FINAL_WAVE_TASK_LINE_PATTERN)
if (finalWaveMatch?.[1] && finalWaveMatch[2]) {
const label = finalWaveMatch[1].toUpperCase()
return {
key: `final-wave:${label.toLowerCase()}`,
label,
title: finalWaveMatch[2].trim(),
}
}
const todoMatch = candidate.match(TODO_TASK_LINE_PATTERN)
if (todoMatch?.[1] && todoMatch[2]) {
const label = todoMatch[1]
return {
key: `todo:${label}`,
label,
title: todoMatch[2].trim(),
}
}
}
return null
}
export function createToolExecuteBeforeHandler(input: {
ctx: PluginInput
pendingFilePaths: Map<string, string>
pendingTaskRefs: Map<string, PendingTaskRef>
pendingPlanSnapshots?: Map<string, string>
isCallerOrchestrator?: (sessionID: string | undefined) => Promise<boolean>
}): (
toolInput: { tool: string; sessionID?: string; callID?: string },
toolOutput: { args: Record<string, unknown>; message?: string }
) => Promise<void> {
const { ctx, pendingFilePaths, pendingTaskRefs } = input
const { ctx, pendingFilePaths, pendingTaskRefs, pendingPlanSnapshots } = input
const resolveIsCallerOrchestrator = input.isCallerOrchestrator ?? ((sessionID) => isCallerOrchestrator(sessionID, ctx.client))
function trackTask(callID: string, task: TrackedTopLevelTaskRef): void {
pendingTaskRefs.set(callID, { kind: "track", task })
}
return async (toolInput, toolOutput): Promise<void> => {
if (!(await isCallerOrchestrator(toolInput.sessionID, ctx.client))) {
if (!(await resolveIsCallerOrchestrator(toolInput.sessionID))) {
return
}
@@ -32,11 +80,35 @@ export function createToolExecuteBeforeHandler(input: {
// Warn-only policy: Atlas guides orchestrators toward delegation but doesn't block, allowing flexibility for urgent fixes
if (isWriteOrEditToolName(toolInput.tool)) {
const filePath = (toolOutput.args.filePath ?? toolOutput.args.path ?? toolOutput.args.file) as string | undefined
if (filePath && !isSisyphusPath(filePath)) {
// Store filePath for use in tool.execute.after
if (toolInput.callID) {
pendingFilePaths.set(toolInput.callID, filePath)
if (!filePath || !toolInput.callID) {
return
}
// Store filePath for use in tool.execute.after
pendingFilePaths.set(toolInput.callID, filePath)
const sessionID = toolInput.sessionID
const sessionWork = sessionID
? getWorkForSession(ctx.directory, sessionID)
: null
const state = sessionWork ? null : readBoulderState(ctx.directory)
const planPath = sessionWork
? resolveBoulderPlanPathForWork(ctx.directory, sessionWork)
: state
? resolveBoulderPlanPath(ctx.directory, state)
: null
if (planPath && resolve(filePath) === resolve(planPath) && pendingPlanSnapshots) {
try {
if (existsSync(planPath)) {
pendingPlanSnapshots.set(toolInput.callID, readFileSync(planPath, "utf-8"))
}
} catch {
pendingPlanSnapshots.delete(toolInput.callID)
}
}
if (!isOmoPath(filePath)) {
const warning = ORCHESTRATOR_DELEGATION_REQUIRED.replace("$FILE_PATH", filePath)
toolOutput.message = (toolOutput.message || "") + warning
log(`[${HOOK_NAME}] Injected delegation warning for direct file modification`, {
@@ -58,33 +130,48 @@ export function createToolExecuteBeforeHandler(input: {
reason: "explicit_resume",
})
} else {
const prompt = typeof toolOutput.args.prompt === "string" ? toolOutput.args.prompt : ""
const taskFromPrompt = parseTrackedTaskFromPrompt(prompt)
const boulderState = readBoulderState(ctx.directory)
const currentTask = boulderState
? readCurrentTopLevelTask(boulderState.active_plan)
? readCurrentTopLevelTask(resolveBoulderPlanPath(ctx.directory, boulderState))
: null
if (currentTask) {
const task = {
key: currentTask.key,
label: currentTask.label,
title: currentTask.title,
const resolvedTask = taskFromPrompt ?? (currentTask
? {
key: currentTask.key,
label: currentTask.label,
title: currentTask.title,
}
: null)
if (resolvedTask) {
if (!taskFromPrompt) {
log(`[${HOOK_NAME}] TASK section parse failed; falling back to current top-level task`, {
sessionID: toolInput.sessionID,
callID: toolInput.callID,
})
}
const trackedTask = {
key: resolvedTask.key,
label: resolvedTask.label,
title: resolvedTask.title,
}
const hasExistingClaim = [...pendingTaskRefs.values()].some((pendingTaskRef) => (
pendingTaskRef.kind === "track" && pendingTaskRef.task.key === task.key
pendingTaskRef.kind === "track" && pendingTaskRef.task.key === trackedTask.key
))
if (hasExistingClaim) {
pendingTaskRefs.set(toolInput.callID, {
kind: "skip",
reason: "ambiguous_task_key",
task,
task: trackedTask,
})
log(`[${HOOK_NAME}] Skipping task session persistence for ambiguous task key`, {
sessionID: toolInput.sessionID,
callID: toolInput.callID,
taskKey: task.key,
taskKey: trackedTask.key,
})
} else {
trackTask(toolInput.callID, task)
trackTask(toolInput.callID, trackedTask)
}
}
}
+9 -2
View File
@@ -1,14 +1,19 @@
import type { AgentOverrides } from "../../config"
import type { BackgroundManager } from "../../features/background-agent"
import type { TopLevelTaskRef } from "../../features/boulder-state"
export type ModelInfo = { providerID: string; modelID: string; variant?: string }
export interface BackgroundTaskStatusProvider {
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
}
export interface AtlasHookOptions {
directory: string
backgroundManager?: BackgroundManager
backgroundManager?: BackgroundTaskStatusProvider
isContinuationStopped?: (sessionID: string) => boolean
isCallerOrchestrator?: (sessionID: string | undefined) => Promise<boolean>
agentOverrides?: AgentOverrides
idleSettleMs?: number
/** Enable auto-commit after each atomic task completion (default: true) */
autoCommit?: boolean
}
@@ -34,6 +39,7 @@ export type PendingTaskRef =
export interface SessionState {
lastEventWasAbortError?: boolean
skipNextIdleAfterRuntimeErrorRetry?: boolean
lastContinuationInjectedAt?: number
isInjectingContinuation?: boolean
promptFailureCount: number
@@ -42,4 +48,5 @@ export interface SessionState {
waitingForFinalWaveApproval?: boolean
pendingFinalWaveTaskCount?: number
approvedFinalWaveTaskCount?: number
boulderCompletionNudgedAt?: Record<string, number>
}
@@ -26,7 +26,7 @@ describe("buildCompletionGate", () => {
then("gate interpolates the plan name path", () => {
expect(gate).toContain(planName)
expect(gate).toContain(`.sisyphus/plans/${planName}.md`)
expect(gate).toContain(`.omo/plans/${planName}.md`)
})
then("gate includes Edit instructions", () => {
+5 -5
View File
@@ -15,13 +15,13 @@ export function buildCompletionGate(planName: string, sessionId: string): string
Your completion will NOT be recorded until you complete ALL of the following:
1. **Edit** the plan file \`.sisyphus/plans/${planName}.md\`:
1. **Edit** the plan file \`.omo/plans/${planName}.md\`:
- Change \`- [ ]\` to \`- [x]\` for the completed task
- Use \`Edit\` tool to modify the checkbox
2. **Read** the plan file AGAIN:
\`\`\`
Read(".sisyphus/plans/${planName}.md")
Read(".omo/plans/${planName}.md")
\`\`\`
- Verify the checkbox count changed (more \`- [x]\` than before)
@@ -88,7 +88,7 @@ ${includeCompletionGate ? `${buildCompletionGate(planName, sessionId)}
The subagent was instructed to record findings in notepad files. Read them NOW:
\`\`\`
Glob(".sisyphus/notepads/${planName}/*.md")
Glob(".omo/notepads/${planName}/*.md")
\`\`\`
Then \`Read\` each file found - especially:
- **learnings.md**: Patterns, conventions, successful approaches discovered
@@ -104,7 +104,7 @@ Then \`Read\` each file found - especially:
Do NOT rely on cached progress. Read the plan file NOW:
\`\`\`
Read(".sisyphus/plans/${planName}.md")
Read(".omo/plans/${planName}.md")
\`\`\`
Count exactly: how many \`- [ ]\` remain? How many \`- [x]\` completed?
This is YOUR ground truth. Use it to decide what comes next.
@@ -143,7 +143,7 @@ The last Final Verification Wave result just passed.
This is the ONLY point where approval-style user interaction is required.
1. Read \
\`.sisyphus/plans/${planName}.md\` again and confirm every remaining unchecked **top-level** task belongs to F1-F4.
\`.omo/plans/${planName}.md\` again and confirm every remaining unchecked **top-level** task belongs to F1-F4.
Ignore nested checkboxes under Acceptance Criteria, Evidence, or Final Checklist sections.
2. Consolidate the F1-F4 verdicts into a short summary for the user.
3. Tell the user all final reviewers approved.
+1 -1
View File
@@ -1,4 +1,4 @@
const WRITE_EDIT_TOOLS = ["Write", "Edit", "write", "edit"]
const WRITE_EDIT_TOOLS = ["Write", "Edit", "write", "edit", "hashline_edit"]
export function isWriteOrEditToolName(toolName: string): boolean {
return WRITE_EDIT_TOOLS.includes(toolName)
+51 -3
View File
@@ -1,10 +1,12 @@
import { describe, expect, it } from "bun:test"
import { OMO_INTERNAL_INITIATOR_MARKER } from "../../shared/internal-initiator-marker"
import {
parseSlashCommand,
detectSlashCommand,
isExcludedCommand,
removeCodeBlocks,
extractPromptText,
findSlashCommandPartIndex,
isExcludedCommand,
parseSlashCommand,
removeCodeBlocks,
} from "./detector"
describe("auto-slash-command detector", () => {
@@ -305,5 +307,51 @@ After`
// then should return empty string
expect(result).toBe("")
})
it("ignores synthetic and internal slash text when extracting prompt text", () => {
// given
const parts = [
{ type: "text", text: "/commit from synthetic", synthetic: true },
{ type: "text", text: `/commit from marker\n${OMO_INTERNAL_INITIATOR_MARKER}` },
{ type: "text", text: "real request" },
]
// when
const result = extractPromptText(parts)
// then
expect(result).toBe("real request")
})
})
describe("findSlashCommandPartIndex", () => {
it("does not select synthetic or internal slash command parts", () => {
// given
const parts = [
{ type: "text", text: "/commit synthetic", synthetic: true },
{ type: "text", text: `/plan internal\n${OMO_INTERNAL_INITIATOR_MARKER}` },
{ type: "text", text: "/real-command" },
]
// when
const result = findSlashCommandPartIndex(parts)
// then
expect(result).toBe(2)
})
it("returns minus one when every slash command part is synthetic or internal", () => {
// given
const parts = [
{ type: "text", text: "/commit synthetic", synthetic: true },
{ type: "text", text: `/plan internal\n${OMO_INTERNAL_INITIATOR_MARKER}` },
]
// when
const result = findSlashCommandPartIndex(parts)
// then
expect(result).toBe(-1)
})
})
})
+6 -12
View File
@@ -1,6 +1,7 @@
import { isRealUserTextPart } from "../../shared/internal-initiator-marker"
import {
SLASH_COMMAND_PATTERN,
EXCLUDED_COMMANDS,
SLASH_COMMAND_PATTERN,
} from "./constants"
import type { ParsedSlashCommand } from "./types"
@@ -56,30 +57,23 @@ export function detectSlashCommand(text: string): ParsedSlashCommand | null {
}
export function extractPromptText(
parts: Array<{ type: string; text?: string }>
parts: Array<{ type: string; text?: string; synthetic?: boolean }>
): string {
const textParts = parts.filter((p) => p.type === "text")
const textParts = parts.filter(isRealUserTextPart)
const slashPart = textParts.find((p) => (p.text ?? "").trim().startsWith("/"))
if (slashPart?.text) {
return slashPart.text
}
const nonSyntheticParts = textParts.filter(
(p) => !(p as { synthetic?: boolean }).synthetic
)
if (nonSyntheticParts.length > 0) {
return nonSyntheticParts.map((p) => p.text || "").join(" ")
}
return textParts.map((p) => p.text || "").join(" ")
}
export function findSlashCommandPartIndex(
parts: Array<{ type: string; text?: string }>
parts: Array<{ type: string; text?: string; synthetic?: boolean }>
): number {
for (let idx = 0; idx < parts.length; idx += 1) {
const part = parts[idx]
if (part.type !== "text") continue
if (!isRealUserTextPart(part)) continue
if ((part.text ?? "").trim().startsWith("/")) {
return idx
}
@@ -1,8 +1,9 @@
/// <reference types="bun-types" />
import { afterEach, describe, expect, it, spyOn } from "bun:test"
import type { LoadedSkill } from "../../features/opencode-skill-loader"
import * as shared from "../../shared"
import * as slashcommand from "../../tools/slashcommand"
import { executeSlashCommand } from "./executor"
import * as slashcommand from "../../tools/slashcommand/command-discovery"
let resolveCommandsInTextSpy: { mockRestore: () => void } | undefined
let resolveFileReferencesInTextSpy: { mockRestore: () => void } | undefined
@@ -38,6 +39,11 @@ function restoreExecutorSpies(): void {
discoverCommandsSyncSpy = undefined
}
async function executeSlashCommand(...args: Parameters<typeof import("./executor").executeSlashCommand>): ReturnType<typeof import("./executor").executeSlashCommand> {
const module = await import(`./executor?test=${Date.now()}-${Math.random()}`)
return module.executeSlashCommand(...args)
}
afterEach(restoreExecutorSpies)
function createRestrictedSkill(): LoadedSkill {
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { clearCommandLoaderCache } from "../../features/claude-code-command-loader"
import { executeSlashCommand } from "./executor"
const ENV_KEYS = [
@@ -95,6 +96,7 @@ describe("auto-slash command executor plugin dispatch", () => {
let envSnapshot: EnvSnapshot
beforeEach(() => {
clearCommandLoaderCache()
tempDir = mkdtempSync(join(tmpdir(), "omo-executor-plugin-test-"))
envSnapshot = {
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
@@ -106,6 +108,7 @@ describe("auto-slash command executor plugin dispatch", () => {
})
afterEach(() => {
clearCommandLoaderCache()
for (const key of ENV_KEYS) {
const previousValue = envSnapshot[key]
if (previousValue === undefined) {
+4 -6
View File
@@ -1,10 +1,8 @@
import { dirname } from "path"
import {
resolveCommandsInText,
resolveFileReferencesInText,
} from "../../shared"
import { resolveCommandsInText } from "../../shared/command-executor/resolve-commands-in-text"
import { resolveFileReferencesInText } from "../../shared/file-reference-resolver"
import { discoverAllSkills, type LoadedSkill, type LazyContentLoader } from "../../features/opencode-skill-loader"
import { discoverCommandsSync } from "../../tools/slashcommand"
import * as commandDiscovery from "../../tools/slashcommand/command-discovery"
import type { CommandInfo as DiscoveredCommandInfo, CommandMetadata } from "../../tools/slashcommand/types"
import type { ParsedSlashCommand } from "./types"
@@ -47,7 +45,7 @@ export interface ExecutorOptions {
async function discoverAllCommands(options?: ExecutorOptions): Promise<CommandInfo[]> {
const discoveredCommands = discoverCommandsSync(options?.directory ?? process.cwd(), {
const discoveredCommands = commandDiscovery.discoverCommandsSync(options?.directory ?? process.cwd(), {
pluginsEnabled: options?.pluginsEnabled,
enabledPluginsOverride: options?.enabledPluginsOverride,
})
+3 -11
View File
@@ -5,6 +5,7 @@ import {
} from "./detector"
import { executeSlashCommand, type ExecutorOptions } from "./executor"
import { log } from "../../shared"
import { resolveSessionEventID } from "../../shared/event-session-id"
import {
AUTO_SLASH_COMMAND_TAG_CLOSE,
AUTO_SLASH_COMMAND_TAG_OPEN,
@@ -25,16 +26,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
}
function getDeletedSessionID(properties: unknown): string | null {
if (!isRecord(properties)) {
return null
}
const info = properties.info
if (!isRecord(info)) {
return null
}
return typeof info.id === "string" ? info.id : null
return resolveSessionEventID(properties) ?? null
}
function getCommandExecutionEventID(input: CommandExecuteBeforeInput): string | null {
@@ -49,7 +41,7 @@ function getCommandExecutionEventID(input: CommandExecuteBeforeInput): string |
"commandId",
]
const recordInput = input as unknown
const recordInput: unknown = input
if (!isRecord(recordInput)) {
return null
}
+26 -5
View File
@@ -1,8 +1,11 @@
import { describe, expect, it, beforeEach, afterEach, spyOn, mock } from "bun:test"
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { clearCommandLoaderCache } from "../../features/claude-code-command-loader"
import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
// Import real shared module to avoid mock leaking to other test files
import * as shared from "../../shared"
import type {
AutoSlashCommandHookInput,
AutoSlashCommandHookOutput,
@@ -10,9 +13,6 @@ import type {
CommandExecuteBeforeOutput,
} from "./types"
// Import real shared module to avoid mock leaking to other test files
import * as shared from "../../shared"
type AutoSlashCommandModule = typeof import("./hook")
function createMockInput(sessionID: string, messageID?: string): AutoSlashCommandHookInput {
@@ -43,6 +43,7 @@ describe("createAutoSlashCommandHook", () => {
let createAutoSlashCommandHook: AutoSlashCommandModule["createAutoSlashCommandHook"]
beforeEach(async () => {
clearCommandLoaderCache()
mock.restore()
logCalls = []
spyOn(shared, "log").mockImplementation((message: string, data?: unknown) => {
@@ -56,6 +57,7 @@ describe("createAutoSlashCommandHook", () => {
})
afterEach(() => {
clearCommandLoaderCache()
process.chdir(originalWorkingDirectory)
rmSync(tempDir, { recursive: true, force: true })
mock.restore()
@@ -420,6 +422,25 @@ describe("createAutoSlashCommandHook", () => {
expect(output.parts[0].text).toContain("This is the skill template content")
})
it("does not replace synthetic slash text with a skill template", async () => {
// given
const skill = createTestSkill("my-test-skill", "This is the skill template content")
const hook = createAutoSlashCommandHook({ skills: [skill] })
const sessionID = `test-session-skill-synthetic-${Date.now()}`
const input = createMockInput(sessionID)
const output: AutoSlashCommandHookOutput = {
message: {},
parts: [{ type: "text", text: "/my-test-skill some arguments", synthetic: true }],
}
const originalText = output.parts[0].text
// when
await hook["chat.message"](input, output)
// then
expect(output.parts[0].text).toBe(originalText)
})
it("should inject skill template via command.execute.before", async () => {
// given a hook with a skill
const skill = createTestSkill("my-test-skill", "Skill template for command execute")
@@ -4,7 +4,10 @@ import { tmpdir } from "node:os"
import { join } from "node:path"
// Hold mutable mock state so beforeEach can swap the cache root for each test.
const mockState: { candidates: string[] } = { candidates: [] }
const mockState: { candidates: string[]; walkUpResult: string | null } = {
candidates: [],
walkUpResult: null,
}
mock.module("../constants", () => ({
INSTALLED_PACKAGE_JSON_CANDIDATES: new Proxy([], {
@@ -12,7 +15,7 @@ mock.module("../constants", () => ({
const current = mockState.candidates
// Forward array methods/properties to the mutable candidates list
// so getCachedVersion's `for (... of ...)` sees fresh data per test.
const value = (current as unknown as Record<PropertyKey, unknown>)[prop]
const value = (unsafeTestValue<Record<PropertyKey, unknown>>(current))[prop]
if (typeof value === "function") {
return (value as (...args: unknown[]) => unknown).bind(current)
}
@@ -22,10 +25,11 @@ mock.module("../constants", () => ({
}))
mock.module("./package-json-locator", () => ({
findPackageJsonUp: () => null,
findPackageJsonUp: () => mockState.walkUpResult,
}))
import { getCachedVersion } from "./cached-version"
import { unsafeTestValue } from "../../../../test-support/unsafe-test-value"
describe("getCachedVersion (GH-3257)", () => {
let cacheRoot: string
@@ -36,11 +40,13 @@ describe("getCachedVersion (GH-3257)", () => {
join(cacheRoot, "node_modules", "oh-my-opencode", "package.json"),
join(cacheRoot, "node_modules", "oh-my-openagent", "package.json"),
]
mockState.walkUpResult = null
})
afterEach(() => {
rmSync(cacheRoot, { recursive: true, force: true })
mockState.candidates = []
mockState.walkUpResult = null
})
it("returns the version when the package is installed under oh-my-opencode", () => {
@@ -77,4 +83,23 @@ describe("getCachedVersion (GH-3257)", () => {
it("returns null when neither candidate exists and fallbacks find nothing", () => {
expect(getCachedVersion()).toBeNull()
})
it("prefers the loaded module's package.json over flat-install candidates", () => {
// OpenCode loads plugins from a per-plugin sandbox at
// <CACHE_DIR>/<plugin-entry>/node_modules/<pkg>/, while a parallel flat
// install at <CACHE_DIR>/node_modules/<pkg>/ can drift independently when
// bun re-resolves "latest". The flat install must NOT take precedence,
// because that's the path the user is actually running.
const sandboxDir = join(cacheRoot, "oh-my-openagent@latest", "node_modules", "oh-my-openagent")
mkdirSync(sandboxDir, { recursive: true })
const sandboxPkgJson = join(sandboxDir, "package.json")
writeFileSync(sandboxPkgJson, JSON.stringify({ name: "oh-my-openagent", version: "3.17.5" }))
mockState.walkUpResult = sandboxPkgJson
const flatDir = join(cacheRoot, "node_modules", "oh-my-opencode")
mkdirSync(flatDir, { recursive: true })
writeFileSync(join(flatDir, "package.json"), JSON.stringify({ name: "oh-my-opencode", version: "3.17.6" }))
expect(getCachedVersion()).toBe("3.17.5")
})
})
@@ -13,16 +13,12 @@ function readPackageVersion(packageJsonPath: string): string | null {
}
export function getCachedVersion(): string | null {
for (const candidate of INSTALLED_PACKAGE_JSON_CANDIDATES) {
try {
if (fs.existsSync(candidate)) {
return readPackageVersion(candidate)
}
} catch {
// ignore; try next candidate
}
}
// Walk up from the loaded module first. OpenCode loads plugins from a
// per-plugin sandbox at <CACHE_DIR>/<plugin-entry>/node_modules/<pkg>/, while
// a parallel flat install at <CACHE_DIR>/node_modules/<pkg>/ can drift
// independently when bun re-resolves "latest". Reading the flat install
// first means the toast can announce a version the runtime isn't running.
// The module-relative walk-up always reflects what is actually loaded.
try {
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const pkgPath = findPackageJsonUp(currentDir)
@@ -33,6 +29,16 @@ export function getCachedVersion(): string | null {
log("[auto-update-checker] Failed to resolve version from current directory:", err)
}
for (const candidate of INSTALLED_PACKAGE_JSON_CANDIDATES) {
try {
if (fs.existsSync(candidate)) {
return readPackageVersion(candidate)
}
} catch {
// ignore; try next candidate
}
}
try {
const execDir = path.dirname(fs.realpathSync(process.execPath))
const pkgPath = findPackageJsonUp(execDir)
@@ -1,4 +1,5 @@
import { log } from "../../../shared/logger"
import { compareVersions } from "../../../shared/opencode-version"
import type { UpdateCheckResult } from "../types"
import { extractChannel } from "../version-channel"
import { isLocalDevMode } from "./local-dev-path"
@@ -55,7 +56,7 @@ export async function checkForUpdate(directory: string): Promise<UpdateCheckResu
}
}
const needsUpdate = currentVersion !== latestVersion
const needsUpdate = compareVersions(currentVersion, latestVersion) !== 0
log(
`[auto-update-checker] Current: ${currentVersion}, Latest (${channel}): ${latestVersion}, NeedsUpdate: ${needsUpdate}`
)
+261
View File
@@ -0,0 +1,261 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { describe, expect, mock, test } from "bun:test"
type CreateAutoUpdateCheckerHook = typeof import("./hook").createAutoUpdateCheckerHook
type HookOptions = Parameters<CreateAutoUpdateCheckerHook>[1]
type HookDeps = NonNullable<Parameters<CreateAutoUpdateCheckerHook>[2]>
let latestVersionCallCount = 0
let scheduleDeferredStartupCheckCallCount = 0
const flushMicrotasks = async (count: number): Promise<void> => {
for (let index = 0; index < count; index += 1) {
await Promise.resolve()
}
}
const latestVersionMock = async () => {
latestVersionCallCount += 1
return "3.0.1"
}
const scheduleDeferredStartupCheckMock = (runCheck: () => void) => {
scheduleDeferredStartupCheckCallCount += 1
scheduledCheck = runCheck
}
let scheduledCheck: (() => void) | null = null
mock.module("./checker/latest-version", () => ({
getLatestVersion: latestVersionMock,
}))
mock.module("./hook/deferred-startup-check", () => ({
scheduleDeferredStartupCheck: scheduleDeferredStartupCheckMock,
}))
const createPluginInput = (): PluginInput => ({
client: {} as PluginInput["client"],
directory: "/tmp/project",
project: {} as PluginInput["project"],
worktree: "/tmp/project",
serverUrl: new URL("https://example.com"),
$: {} as PluginInput["$"],
} satisfies PluginInput)
const createDeps = (overrides: Partial<HookDeps> = {}) => {
const showConfigErrorsIfAny = mock(async () => undefined)
const updateAndShowConnectedProvidersCacheStatus = mock(async () => undefined)
const refreshModelCapabilitiesOnStartup = mock(async () => undefined)
const showModelCacheWarningIfNeeded = mock(async () => undefined)
const showLocalDevToast = mock(async () => undefined)
const showVersionToast = mock(async () => undefined)
const runBackgroundUpdateCheck = mock(async () => {
await latestVersionMock()
})
const deps: HookDeps = {
getCachedVersion: () => "3.0.0",
getLocalDevVersion: () => null,
showConfigErrorsIfAny,
updateAndShowConnectedProvidersCacheStatus,
refreshModelCapabilitiesOnStartup,
showModelCacheWarningIfNeeded,
showLocalDevToast,
showVersionToast,
runBackgroundUpdateCheck,
log: () => undefined,
...overrides,
}
return {
deps,
mocks: {
showConfigErrorsIfAny,
updateAndShowConnectedProvidersCacheStatus,
refreshModelCapabilitiesOnStartup,
showModelCacheWarningIfNeeded,
showLocalDevToast,
showVersionToast,
runBackgroundUpdateCheck,
},
}
}
const createHook = async (
options: HookOptions = {},
overrides: Partial<HookDeps> = {},
) => {
const module = await import("./hook")
const { deps, mocks } = createDeps(overrides)
return {
hook: module.createAutoUpdateCheckerHook(
createPluginInput(),
{
showStartupToast: true,
autoUpdate: false,
...options,
},
deps,
),
mocks,
}
}
const resetDeferredState = (): void => {
latestVersionCallCount = 0
scheduleDeferredStartupCheckCallCount = 0
scheduledCheck = null
}
const runScheduledCheck = async (): Promise<void> => {
scheduledCheck?.()
await flushMicrotasks(8)
}
const triggerSessionCreated = (
hook: ReturnType<CreateAutoUpdateCheckerHook>,
properties?: { info?: { parentID?: string } },
): void => {
hook.event({ event: { type: "session.created", properties } })
}
const triggerSessionIdle = (hook: ReturnType<CreateAutoUpdateCheckerHook>): void => {
hook.event({ event: { type: "session.idle" } })
}
describe("auto-update-checker hook", () => {
test("schedules deferred check on session.created without parentID", async () => {
// given
resetDeferredState()
const { hook, mocks } = await createHook()
// when
triggerSessionCreated(hook)
// then
expect(scheduleDeferredStartupCheckCallCount).toBe(1)
expect(mocks.showVersionToast).not.toHaveBeenCalled()
expect(mocks.runBackgroundUpdateCheck).not.toHaveBeenCalled()
expect(latestVersionCallCount).toBe(0)
// when
await runScheduledCheck()
// then
expect(mocks.showVersionToast).toHaveBeenCalledTimes(1)
expect(mocks.runBackgroundUpdateCheck).toHaveBeenCalledTimes(1)
expect(latestVersionCallCount).toBe(1)
})
test("does not schedule deferred check on session.created with parentID", async () => {
// given
resetDeferredState()
const { hook, mocks } = await createHook()
// when
triggerSessionCreated(hook, { info: { parentID: "parent-123" } })
// then
expect(scheduleDeferredStartupCheckCallCount).toBe(0)
expect(mocks.showVersionToast).not.toHaveBeenCalled()
expect(mocks.runBackgroundUpdateCheck).not.toHaveBeenCalled()
})
test("does not schedule deferred check on session.idle without session.created", async () => {
// given
resetDeferredState()
const { hook, mocks } = await createHook()
// when
triggerSessionIdle(hook)
// then
expect(scheduleDeferredStartupCheckCallCount).toBe(0)
expect(mocks.showVersionToast).not.toHaveBeenCalled()
expect(mocks.runBackgroundUpdateCheck).not.toHaveBeenCalled()
})
test("runs all startup checks after deferred session.created check executes", async () => {
// given
resetDeferredState()
const { hook, mocks } = await createHook()
// when
triggerSessionCreated(hook)
await runScheduledCheck()
// then
expect(mocks.showConfigErrorsIfAny).toHaveBeenCalledTimes(1)
expect(mocks.updateAndShowConnectedProvidersCacheStatus).toHaveBeenCalledTimes(1)
expect(mocks.refreshModelCapabilitiesOnStartup).toHaveBeenCalledTimes(1)
expect(mocks.showModelCacheWarningIfNeeded).toHaveBeenCalledTimes(1)
expect(mocks.showVersionToast).toHaveBeenCalledTimes(1)
expect(mocks.runBackgroundUpdateCheck).toHaveBeenCalledTimes(1)
})
test("guards double execution across repeated session.created events", async () => {
// given
resetDeferredState()
const { hook, mocks } = await createHook()
// when
triggerSessionCreated(hook)
triggerSessionCreated(hook)
// then
expect(scheduleDeferredStartupCheckCallCount).toBe(1)
// when
await runScheduledCheck()
triggerSessionCreated(hook)
// then
expect(scheduleDeferredStartupCheckCallCount).toBe(1)
expect(mocks.showConfigErrorsIfAny).toHaveBeenCalledTimes(1)
expect(mocks.updateAndShowConnectedProvidersCacheStatus).toHaveBeenCalledTimes(1)
expect(mocks.showModelCacheWarningIfNeeded).toHaveBeenCalledTimes(1)
expect(mocks.showVersionToast).toHaveBeenCalledTimes(1)
expect(mocks.runBackgroundUpdateCheck).toHaveBeenCalledTimes(1)
})
test("shows localDevToast when local dev version exists", async () => {
// given
resetDeferredState()
const { hook, mocks } = await createHook({}, {
getLocalDevVersion: () => "3.0.0-dev",
})
// when
triggerSessionCreated(hook)
await runScheduledCheck()
// then
expect(mocks.showConfigErrorsIfAny).toHaveBeenCalledTimes(1)
expect(mocks.updateAndShowConnectedProvidersCacheStatus).toHaveBeenCalledTimes(1)
expect(mocks.showModelCacheWarningIfNeeded).toHaveBeenCalledTimes(1)
expect(mocks.showLocalDevToast).toHaveBeenCalledTimes(1)
expect(mocks.showVersionToast).not.toHaveBeenCalled()
expect(mocks.runBackgroundUpdateCheck).not.toHaveBeenCalled()
expect(latestVersionCallCount).toBe(0)
})
test("passes correct toast message with sisyphus enabled", async () => {
// given
resetDeferredState()
const { hook, mocks } = await createHook({ isSisyphusEnabled: true })
// when
triggerSessionCreated(hook)
await runScheduledCheck()
// then
expect(mocks.showVersionToast).toHaveBeenCalledTimes(1)
expect(mocks.showVersionToast).toHaveBeenCalledWith(
expect.anything(),
"3.0.0",
expect.stringContaining("Sisyphus"),
)
})
})
+42 -25
View File
@@ -3,6 +3,7 @@ import { log } from "../../shared/logger"
import type { AutoUpdateCheckerOptions } from "./types"
import { getCachedVersion, getLocalDevVersion } from "./checker"
import { runBackgroundUpdateCheck } from "./hook/background-update-check"
import { scheduleDeferredStartupCheck } from "./hook/deferred-startup-check"
import { showConfigErrorsIfAny } from "./hook/config-errors-toast"
import { updateAndShowConnectedProvidersCacheStatus } from "./hook/connected-providers-status"
import { refreshModelCapabilitiesOnStartup } from "./hook/model-capabilities-status"
@@ -35,6 +36,20 @@ const defaultDeps: AutoUpdateCheckerDeps = {
log,
}
const isRecord = (value: unknown): value is Record<string, unknown> => {
return typeof value === "object" && value !== null
}
const getParentID = (properties: unknown): string | undefined => {
if (!isRecord(properties)) return undefined
const { info } = properties
if (!isRecord(info)) return undefined
const { parentID } = info
return typeof parentID === "string" && parentID.length > 0 ? parentID : undefined
}
export function createAutoUpdateCheckerHook(
ctx: PluginInput,
options: AutoUpdateCheckerOptions = {},
@@ -60,44 +75,46 @@ export function createAutoUpdateCheckerHook(
}
let hasChecked = false
let hasScheduled = false
return {
event: ({ event }: { event: { type: string; properties?: unknown } }) => {
if (event.type !== "session.created") return
if (isCliRunMode) return
if (hasChecked) return
if (hasChecked || hasScheduled) return
if (getParentID(event.properties)) return
const props = event.properties as { info?: { parentID?: string } } | undefined
if (props?.info?.parentID) return
hasScheduled = true
scheduleDeferredStartupCheck(() => {
hasChecked = true
void (async () => {
const cachedVersion = deps.getCachedVersion()
const localDevVersion = deps.getLocalDevVersion(ctx.directory)
const displayVersion = localDevVersion ?? cachedVersion
setTimeout(async () => {
const cachedVersion = deps.getCachedVersion()
const localDevVersion = deps.getLocalDevVersion(ctx.directory)
const displayVersion = localDevVersion ?? cachedVersion
await deps.showConfigErrorsIfAny(ctx)
await deps.updateAndShowConnectedProvidersCacheStatus(ctx)
await deps.refreshModelCapabilitiesOnStartup(modelCapabilities)
await deps.showModelCacheWarningIfNeeded(ctx)
await deps.showConfigErrorsIfAny(ctx)
await deps.updateAndShowConnectedProvidersCacheStatus(ctx)
await deps.refreshModelCapabilitiesOnStartup(modelCapabilities)
await deps.showModelCacheWarningIfNeeded(ctx)
if (localDevVersion) {
if (showStartupToast) {
deps.showLocalDevToast(ctx, displayVersion, isSisyphusEnabled).catch(() => {})
if (localDevVersion) {
if (showStartupToast) {
deps.showLocalDevToast(ctx, displayVersion, isSisyphusEnabled).catch(() => {})
}
deps.log("[auto-update-checker] Local development mode")
return
}
deps.log("[auto-update-checker] Local development mode")
return
}
if (showStartupToast) {
deps.showVersionToast(ctx, displayVersion, getToastMessage(false)).catch(() => {})
}
if (showStartupToast) {
deps.showVersionToast(ctx, displayVersion, getToastMessage(false)).catch(() => {})
}
deps.runBackgroundUpdateCheck(ctx, autoUpdate, getToastMessage).catch((err) => {
deps.log("[auto-update-checker] Background update check failed:", err)
})
}, 0)
deps.runBackgroundUpdateCheck(ctx, autoUpdate, getToastMessage).catch((err) => {
deps.log("[auto-update-checker] Background update check failed:", err)
})
})()
})
},
}
}
@@ -0,0 +1,4 @@
export function scheduleDeferredStartupCheck(runCheck: () => void): void {
const timeout = setTimeout(runCheck, 5000)
timeout.unref?.()
}
@@ -28,12 +28,6 @@ const FORWARDED_EVENT_TYPES = new Set([
"session.status",
])
/**
* Background notification hook - handles event routing to BackgroundManager.
*
* Notifications are now delivered directly via session.prompt({ noReply })
* from the manager, so this hook only needs to handle event routing.
*/
export function createBackgroundNotificationHook(manager: BackgroundManager) {
const eventHandler = async ({ event }: EventInput) => {
if (!FORWARDED_EVENT_TYPES.has(event.type)) return
+2 -9
View File
@@ -3,6 +3,7 @@ import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder"
import { getSessionAgent } from "../../features/claude-code-session-state"
import { log } from "../../shared"
import { getAgentConfigKey } from "../../shared/agent-display-names"
import { resolveSessionEventID } from "../../shared/event-session-id"
import { buildReminderMessage } from "./formatter"
/**
@@ -120,15 +121,7 @@ export function createCategorySkillReminderHook(
const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id) {
sessionStates.delete(sessionInfo.id)
}
}
if (event.type === "session.compacted") {
const sessionID = (props?.sessionID ??
(props?.info as { id?: string } | undefined)?.id) as string | undefined
const sessionID = resolveSessionEventID(props)
if (sessionID) {
sessionStates.delete(sessionID)
}
@@ -3,6 +3,7 @@ import { createCategorySkillReminderHook } from "./index"
import { updateSessionAgent, clearSessionAgent, _resetForTesting } from "../../features/claude-code-session-state"
import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder"
import * as sharedModule from "../../shared"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
describe("category-skill-reminder hook", () => {
let logCalls: Array<{ msg: string; data?: unknown }>
@@ -21,13 +22,13 @@ describe("category-skill-reminder hook", () => {
})
function createMockPluginInput() {
return {
return unsafeTestValue({
client: {
tui: {
showToast: async () => {},
},
},
} as any
})
}
function createHook(availableSkills: AvailableSkill[] = []) {
@@ -281,7 +282,7 @@ describe("category-skill-reminder hook", () => {
clearSessionAgent(sessionID)
})
test("should reset state on session.compacted event", async () => {
test("should preserve suppression state on session.compacted event", async () => {
// given - sisyphus agent with reminder already shown
const hook = createHook()
const sessionID = "compact-session"
@@ -301,8 +302,30 @@ describe("category-skill-reminder hook", () => {
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "5" }, output2)
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "6" }, output2)
// then - reminder should be shown again (state was reset)
expect(output2.output).toContain("[Category+Skill Reminder]")
// then - reminder should NOT be shown again (state remains suppressed)
expect(output2.output).not.toContain("[Category+Skill Reminder]")
clearSessionAgent(sessionID)
})
test("should preserve partial tool-call count across session.compacted", async () => {
// given - sisyphus agent with 2 delegatable tool calls
const hook = createHook()
const sessionID = "compact-partial-count-session"
updateSessionAgent(sessionID, "Sisyphus")
const output = { title: "", output: "result", metadata: {} }
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "1" }, output)
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "2" }, output)
expect(output.output).not.toContain("[Category+Skill Reminder]")
// when - the session compacts before the third tool call
await hook.event({ event: { type: "session.compacted", properties: { sessionID } } })
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3" }, output)
// then - the third call should still trigger the reminder
expect(output.output).toContain("[Category+Skill Reminder]")
clearSessionAgent(sessionID)
})
+1 -1
View File
@@ -1,6 +1,6 @@
# src/hooks/claude-code-hooks/ — Claude Code Compatibility
**Generated:** 2026-04-18
**Generated:** 2026-05-15
## OVERVIEW
+2 -1
View File
@@ -3,6 +3,7 @@ import { join } from "path"
import type { ClaudeHookEvent } from "./types"
import { log } from "../../shared/logger"
import { getOpenCodeConfigDir } from "../../shared"
import { bunFile } from "../../shared/bun-file-shim"
const CONFIG_CACHE_TTL_MS = 30_000
@@ -61,7 +62,7 @@ async function loadConfigFromPath(path: string): Promise<PluginExtendedConfig |
}
try {
const content = await Bun.file(path).text()
const content = await bunFile(path).text()
return JSON.parse(content) as PluginExtendedConfig
} catch (error) {
log("Failed to load config", { path, error })
+2 -1
View File
@@ -1,6 +1,7 @@
import { join } from "path"
import { existsSync } from "fs"
import { getClaudeConfigDir } from "../../shared"
import { bunFile } from "../../shared/bun-file-shim"
import type { ClaudeHooksConfig, HookMatcher, HookAction } from "./types"
const CONFIG_CACHE_TTL_MS = 30_000
@@ -126,7 +127,7 @@ export async function loadClaudeHooksConfig(
for (const settingsPath of paths) {
if (existsSync(settingsPath)) {
try {
const content = await Bun.file(settingsPath).text()
const content = await bunFile(settingsPath).text()
const settings = JSON.parse(content) as { hooks?: RawClaudeHooksConfig }
if (settings.hooks) {
const normalizedHooks = normalizeHooksConfig(settings.hooks)
@@ -3,6 +3,7 @@
import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test"
import type { HookHttp } from "./types"
import * as sharedModule from "../../shared"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
const mockFetch = mock(() =>
Promise.resolve(new Response(JSON.stringify({}), { status: 200 }))
@@ -31,7 +32,7 @@ describe("executeHttpHook TLS security", () => {
let logCalls: Array<{ message: string; data?: unknown }>
beforeEach(() => {
globalThis.fetch = mockFetch as unknown as typeof fetch
globalThis.fetch = unsafeTestValue<typeof fetch>(mockFetch)
mockFetch.mockReset()
mockFetch.mockImplementation(() =>
Promise.resolve(new Response(JSON.stringify({}), { status: 200 }))
@@ -1,5 +1,6 @@
import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test"
import type { HookHttp } from "./types"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
const mockFetch = mock(() =>
Promise.resolve(new Response(JSON.stringify({}), { status: 200 }))
@@ -9,7 +10,7 @@ const originalFetch = globalThis.fetch
describe("executeHttpHook", () => {
beforeEach(() => {
globalThis.fetch = mockFetch as unknown as typeof fetch
globalThis.fetch = unsafeTestValue<typeof fetch>(mockFetch)
mockFetch.mockReset()
mockFetch.mockImplementation(() =>
Promise.resolve(new Response(JSON.stringify({}), { status: 200 }))
@@ -33,7 +34,7 @@ describe("executeHttpHook", () => {
await executeHttpHook(hook, stdinData)
expect(mockFetch).toHaveBeenCalledTimes(1)
const [url, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit]
const [url, options] = unsafeTestValue<[string, RequestInit]>(mockFetch.mock.calls[0])
expect(url).toBe("http://localhost:8080/hooks/pre-tool-use")
expect(options.method).toBe("POST")
expect(options.body).toBe(stdinData)
@@ -44,7 +45,7 @@ describe("executeHttpHook", () => {
await executeHttpHook(hook, stdinData)
const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit]
const [, options] = unsafeTestValue<[string, RequestInit]>(mockFetch.mock.calls[0])
const headers = options.headers as Record<string, string>
expect(headers["Content-Type"]).toBe("application/json")
})
@@ -72,7 +73,7 @@ describe("executeHttpHook", () => {
await executeHttpHook(hook, "{}")
const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit]
const [, options] = unsafeTestValue<[string, RequestInit]>(mockFetch.mock.calls[0])
const headers = options.headers as Record<string, string>
expect(headers["Authorization"]).toBe("Bearer secret-123")
})
@@ -88,7 +89,7 @@ describe("executeHttpHook", () => {
await executeHttpHook(hook, "{}")
const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit]
const [, options] = unsafeTestValue<[string, RequestInit]>(mockFetch.mock.calls[0])
const headers = options.headers as Record<string, string>
expect(headers["Authorization"]).toBe("Bearer secret-123")
})
@@ -104,7 +105,7 @@ describe("executeHttpHook", () => {
await executeHttpHook(hook, "{}")
const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit]
const [, options] = unsafeTestValue<[string, RequestInit]>(mockFetch.mock.calls[0])
const headers = options.headers as Record<string, string>
expect(headers["Authorization"]).toBe("Bearer ")
})
@@ -121,7 +122,7 @@ describe("executeHttpHook", () => {
await executeHttpHook(hook, "{}")
const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit]
const [, options] = unsafeTestValue<[string, RequestInit]>(mockFetch.mock.calls[0])
expect(options.signal).toBeDefined()
})
})
@@ -7,6 +7,8 @@ import { clearTranscriptCache } from "../transcript"
import { clearToolInputCache, stopToolInputCacheCleanup } from "../tool-input-cache"
import type { PluginConfig } from "../types"
import { createInternalAgentTextPart, isHookDisabled, log } from "../../../shared"
import { resolveSessionEventID } from "../../../shared/event-session-id"
import { promptAfterSessionIdle } from "../../../shared/prompt-async-gate"
import {
clearAllSessionHookState,
clearSessionHookState,
@@ -26,7 +28,7 @@ export function createSessionEventHandler(
if (event.type === "session.error") {
const props = event.properties as Record<string, unknown> | undefined
const sessionID = props?.sessionID as string | undefined
const sessionID = resolveSessionEventID(props)
if (sessionID) {
sessionErrorState.set(sessionID, {
hasError: true,
@@ -38,13 +40,13 @@ export function createSessionEventHandler(
if (event.type === "session.deleted") {
const props = event.properties as Record<string, unknown> | undefined
const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id) {
parentSessionIdCache.delete(sessionInfo.id)
clearTranscriptCache(sessionInfo.id)
clearToolInputCache(sessionInfo.id)
contextCollector?.clear(sessionInfo.id)
clearSessionHookState(sessionInfo.id)
const sessionID = resolveSessionEventID(props)
if (sessionID) {
parentSessionIdCache.delete(sessionID)
clearTranscriptCache(sessionID)
clearToolInputCache(sessionID)
contextCollector?.clear(sessionID)
clearSessionHookState(sessionID)
}
return
}
@@ -54,7 +56,7 @@ export function createSessionEventHandler(
}
const props = event.properties as Record<string, unknown> | undefined
const sessionID = props?.sessionID as string | undefined
const sessionID = resolveSessionEventID(props)
if (!sessionID) return
const claudeConfig = await loadClaudeHooksConfig()
@@ -107,17 +109,23 @@ export function createSessionEventHandler(
})
} else if (stopResult.block && stopResult.injectPrompt) {
log("Stop hook returned block with inject_prompt", { sessionID })
ctx.client.session
.prompt({
const promptResult = await promptAfterSessionIdle({
client: ctx.client,
sessionID,
source: "claude-code-stop-hook:inject-prompt",
input: {
path: { id: sessionID },
body: {
parts: [createInternalAgentTextPart(stopResult.injectPrompt)],
},
query: { directory: ctx.directory },
})
.catch((err: unknown) =>
log("Failed to inject prompt from Stop hook", { error: String(err) }),
)
},
})
if (promptResult.status === "failed") {
log("Failed to inject prompt from Stop hook", { error: String(promptResult.error) })
} else if (promptResult.status !== "dispatched") {
log("Skipped prompt injection from Stop hook", { sessionID, status: promptResult.status })
}
} else if (stopResult.block) {
log("Stop hook returned block", { sessionID, reason: stopResult.reason })
}
@@ -0,0 +1,252 @@
/// <reference types="bun-types" />
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test"
import type { ClaudeHooksConfig } from "./types"
import type { PreToolUseContext } from "./pre-tool-use"
import * as dispatchHookModule from "./dispatch-hook"
import * as logger from "../../shared/logger"
import { executePreToolUseHooks } from "./pre-tool-use"
function createContext(overrides?: Partial<PreToolUseContext>): PreToolUseContext {
return {
sessionId: "test-session",
toolName: "write",
toolInput: { file_path: "/tmp/test.md", content: "hello" },
cwd: "/tmp",
...overrides,
}
}
function createConfig(matchers: ClaudeHooksConfig["PreToolUse"]): ClaudeHooksConfig {
return { PreToolUse: matchers }
}
describe("executePreToolUseHooks", () => {
let dispatchSpy: ReturnType<typeof spyOn>
beforeEach(() => {
dispatchSpy = spyOn(dispatchHookModule, "dispatchHook")
spyOn(logger, "log").mockImplementation(() => {})
})
afterEach(() => {
mock.restore()
})
it("#given null config #when called #then returns allow", async () => {
const result = await executePreToolUseHooks(createContext(), null)
expect(result.decision).toBe("allow")
})
it("#given no matching hooks #when called #then returns allow", async () => {
const config = createConfig([
{ matcher: "Bash", hooks: [{ type: "command", command: "echo test" }] },
])
const result = await executePreToolUseHooks(createContext({ toolName: "write" }), config)
expect(result.decision).toBe("allow")
})
it("#given hook returns exit code 2 #when called #then returns deny", async () => {
dispatchSpy.mockResolvedValue({ exitCode: 2, stdout: "", stderr: "blocked" })
const config = createConfig([
{ matcher: "Write", hooks: [{ type: "command", command: "echo deny" }] },
])
const result = await executePreToolUseHooks(createContext(), config)
expect(result.decision).toBe("deny")
expect(result.reason).toBe("blocked")
})
it("#given hook returns exit code 1 #when called #then returns ask", async () => {
dispatchSpy.mockResolvedValue({ exitCode: 1, stdout: "", stderr: "needs confirmation" })
const config = createConfig([
{ matcher: "Write", hooks: [{ type: "command", command: "echo ask" }] },
])
const result = await executePreToolUseHooks(createContext(), config)
expect(result.decision).toBe("ask")
expect(result.reason).toBe("needs confirmation")
})
describe("#given multiple hooks with merged config (global + project)", () => {
it("#when first hook allows and second hook denies #then returns deny", async () => {
let callCount = 0
dispatchSpy.mockImplementation(async () => {
callCount++
if (callCount === 1) {
// Global catch-all hook returns "allow" via JSON
return {
exitCode: 0,
stdout: JSON.stringify({ decision: "allow" }),
stderr: "",
}
}
// Project budget guard hook returns exit code 2 (deny)
return { exitCode: 2, stdout: "", stderr: "BUDGET EXCEEDED" }
})
const config = createConfig([
// Global catch-all (no specific matcher = matches everything)
{ matcher: "*", hooks: [{ type: "command", command: "node pre-tool-use.mjs" }] },
// Project budget guard
{ matcher: "Edit|Write", hooks: [{ type: "command", command: "bash budget-guard.sh" }] },
])
const result = await executePreToolUseHooks(createContext(), config)
expect(callCount).toBe(2)
expect(result.decision).toBe("deny")
expect(result.reason).toBe("BUDGET EXCEEDED")
})
it("#when first hook allows and second hook also allows #then returns allow", async () => {
let callCount = 0
dispatchSpy.mockImplementation(async () => {
callCount++
if (callCount === 1) {
return {
exitCode: 0,
stdout: JSON.stringify({ decision: "allow" }),
stderr: "",
}
}
return { exitCode: 0, stdout: "", stderr: "" }
})
const config = createConfig([
{ matcher: "*", hooks: [{ type: "command", command: "node pre-tool-use.mjs" }] },
{ matcher: "Edit|Write", hooks: [{ type: "command", command: "bash budget-guard.sh" }] },
])
const result = await executePreToolUseHooks(createContext(), config)
expect(callCount).toBe(2)
expect(result.decision).toBe("allow")
})
it("#when first hook denies #then second hook is NOT executed", async () => {
let callCount = 0
dispatchSpy.mockImplementation(async () => {
callCount++
return { exitCode: 2, stdout: "", stderr: "denied by first hook" }
})
const config = createConfig([
{ matcher: "*", hooks: [{ type: "command", command: "node pre-tool-use.mjs" }] },
{ matcher: "Edit|Write", hooks: [{ type: "command", command: "bash budget-guard.sh" }] },
])
const result = await executePreToolUseHooks(createContext(), config)
expect(callCount).toBe(1)
expect(result.decision).toBe("deny")
})
it("#when first hook allows via JSON with modifiedInput #then input is passed to second hook", async () => {
const capturedStdin: string[] = []
let callCount = 0
dispatchSpy.mockImplementation(async (_hook: unknown, stdinJson: string) => {
capturedStdin.push(stdinJson)
callCount++
if (callCount === 1) {
return {
exitCode: 0,
stdout: JSON.stringify({
decision: "allow",
}),
stderr: "",
}
}
return { exitCode: 0, stdout: "", stderr: "" }
})
const config = createConfig([
{ matcher: "*", hooks: [{ type: "command", command: "node pre-tool-use.mjs" }] },
{ matcher: "Edit|Write", hooks: [{ type: "command", command: "bash budget-guard.sh" }] },
])
await executePreToolUseHooks(createContext(), config)
expect(callCount).toBe(2)
})
it("#when hook returns allow with updatedInput #then modifiedInput is included in final result", async () => {
dispatchSpy.mockResolvedValue({
exitCode: 0,
stdout: JSON.stringify({
decision: "allow",
hookSpecificOutput: {
permissionDecision: "allow",
updatedInput: { file_path: "/tmp/modified.md" },
},
}),
stderr: "",
})
const config = createConfig([
{ matcher: "Write", hooks: [{ type: "command", command: "bash modifier.sh" }] },
])
const result = await executePreToolUseHooks(createContext(), config)
expect(result.decision).toBe("allow")
expect(result.modifiedInput).toEqual({ file_path: "/tmp/modified.md" })
})
it("#when hook returns allow with common fields #then fields are included in final result", async () => {
dispatchSpy.mockResolvedValue({
exitCode: 0,
stdout: JSON.stringify({
decision: "allow",
suppressOutput: true,
systemMessage: "Budget warning: approaching limit",
}),
stderr: "",
})
const config = createConfig([
{ matcher: "Write", hooks: [{ type: "command", command: "bash checker.sh" }] },
])
const result = await executePreToolUseHooks(createContext(), config)
expect(result.decision).toBe("allow")
expect(result.suppressOutput).toBe(true)
expect(result.systemMessage).toBe("Budget warning: approaching limit")
})
it("#when first hook allows with modifiedInput and second hook denies #then deny includes accumulated modifiedInput", async () => {
let callCount = 0
dispatchSpy.mockImplementation(async () => {
callCount++
if (callCount === 1) {
return {
exitCode: 0,
stdout: JSON.stringify({
decision: "allow",
hookSpecificOutput: {
permissionDecision: "allow",
updatedInput: { file_path: "/tmp/modified.md" },
},
}),
stderr: "",
}
}
return { exitCode: 2, stdout: "", stderr: "BUDGET EXCEEDED" }
})
const config = createConfig([
{ matcher: "*", hooks: [{ type: "command", command: "node modifier.mjs" }] },
{ matcher: "Edit|Write", hooks: [{ type: "command", command: "bash budget-guard.sh" }] },
])
const result = await executePreToolUseHooks(createContext(), config)
expect(callCount).toBe(2)
expect(result.decision).toBe("deny")
expect(result.modifiedInput).toEqual({ file_path: "/tmp/modified.md" })
})
})
})
+33 -8
View File
@@ -73,6 +73,13 @@ export async function executePreToolUseHooks(
const startTime = Date.now()
let firstHookName: string | undefined
const inputLines = buildInputLines(ctx.toolInput)
let accumulatedModifiedInput: Record<string, unknown> | undefined
let accumulatedCommonFields: {
continue?: boolean
stopReason?: string
suppressOutput?: boolean
systemMessage?: string
} = {}
for (const matcher of matchers) {
if (!matcher.hooks || matcher.hooks.length === 0) continue
@@ -93,10 +100,12 @@ export async function executePreToolUseHooks(
return {
decision: "deny",
reason: result.stderr || result.stdout || "Hook blocked the operation",
modifiedInput: accumulatedModifiedInput,
elapsedMs: Date.now() - startTime,
hookName: firstHookName,
toolName: transformedToolName,
inputLines,
...accumulatedCommonFields,
}
}
@@ -104,10 +113,12 @@ export async function executePreToolUseHooks(
return {
decision: "ask",
reason: result.stderr || result.stdout,
modifiedInput: accumulatedModifiedInput,
elapsedMs: Date.now() - startTime,
hookName: firstHookName,
toolName: transformedToolName,
inputLines,
...accumulatedCommonFields,
}
}
@@ -143,26 +154,40 @@ export async function executePreToolUseHooks(
output.suppressOutput !== undefined ||
output.systemMessage !== undefined
if (decision || hasCommonFields) {
if (decision === "deny" || decision === "ask") {
return {
decision: decision ?? "allow",
decision,
reason,
modifiedInput,
modifiedInput: modifiedInput ?? accumulatedModifiedInput,
elapsedMs: Date.now() - startTime,
hookName: firstHookName,
toolName: transformedToolName,
inputLines,
continue: output.continue,
stopReason: output.stopReason,
suppressOutput: output.suppressOutput,
systemMessage: output.systemMessage,
continue: output.continue ?? accumulatedCommonFields.continue,
stopReason: output.stopReason ?? accumulatedCommonFields.stopReason,
suppressOutput: output.suppressOutput ?? accumulatedCommonFields.suppressOutput,
systemMessage: output.systemMessage ?? accumulatedCommonFields.systemMessage,
}
}
// "allow" — accumulate modifiedInput and common fields, continue to next hook
if (modifiedInput) {
accumulatedModifiedInput = { ...accumulatedModifiedInput, ...modifiedInput }
Object.assign(stdinData.tool_input, objectToSnakeCase(modifiedInput))
}
if (output.continue !== undefined) accumulatedCommonFields.continue = output.continue
if (output.stopReason !== undefined) accumulatedCommonFields.stopReason = output.stopReason
if (output.suppressOutput !== undefined) accumulatedCommonFields.suppressOutput = output.suppressOutput
if (output.systemMessage !== undefined) accumulatedCommonFields.systemMessage = output.systemMessage
} catch {
}
}
}
}
return { decision: "allow" }
return {
decision: "allow" as const,
...(accumulatedModifiedInput ? { modifiedInput: accumulatedModifiedInput } : {}),
...(Object.keys(accumulatedCommonFields).length > 0 ? accumulatedCommonFields : {}),
}
}
@@ -7,7 +7,12 @@ export const sessionInterruptState = new Map<string, { interrupted: boolean }>()
export function clearSessionHookState(sessionID: string): void {
sessionErrorState.delete(sessionID)
sessionInterruptState.delete(sessionID)
sessionFirstMessageProcessed.delete(sessionID)
// sessionFirstMessageProcessed must NOT be cleared on idle.
// It tracks whether the first message of a session has been processed,
// so that SessionStart hooks fire only once per session. Clearing it
// on idle (which fires after every model response) makes isFirstMessage
// always return true, causing SessionStart hooks to fire on every
// prompt instead of only the first one.
}
export function clearAllSessionHookState(): void {
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
describe("tool-input-cache", () => {
const originalSetInterval = globalThis.setInterval
@@ -33,11 +34,11 @@ describe("tool-input-cache", () => {
test("#given cleanup timer started #when stop cleanup runs #then interval is cleared and cache is emptied", async () => {
//#given
const intervalHandle = { unref: mock(() => {}) } as unknown as ReturnType<typeof setInterval>
const intervalHandle = unsafeTestValue<ReturnType<typeof setInterval>>({ unref: mock(() => {}) })
const setIntervalMock = mock(() => intervalHandle)
const clearIntervalMock = mock(() => {})
globalThis.setInterval = setIntervalMock as unknown as typeof setInterval
globalThis.clearInterval = clearIntervalMock as unknown as typeof clearInterval
globalThis.setInterval = unsafeTestValue<typeof setInterval>(setIntervalMock)
globalThis.clearInterval = unsafeTestValue<typeof clearInterval>(clearIntervalMock)
const modulePath = new URL("./tool-input-cache.ts", import.meta.url).pathname
const cacheModule = await import(`${modulePath}?stop-clear`)
+1 -1
View File
@@ -1,6 +1,6 @@
# src/hooks/comment-checker/ — AI Slop Comment Blocker
**Generated:** 2026-04-18
**Generated:** 2026-05-15
## OVERVIEW
+3 -2
View File
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os"
import { processWithCli } from "./cli-runner"
import type { PendingCall } from "./types"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
function createMockInput() {
return {
@@ -74,7 +75,7 @@ done
const originalSetTimeout = globalThis.setTimeout
globalThis.setTimeout = ((fn: (...args: unknown[]) => void, _ms?: number) => {
fn()
return 0 as unknown as ReturnType<typeof setTimeout>
return unsafeTestValue<ReturnType<typeof setTimeout>>(0)
}) as typeof setTimeout
try {
@@ -102,7 +103,7 @@ done
const originalSetTimeout = globalThis.setTimeout
globalThis.setTimeout = ((fn: (...args: unknown[]) => void, _ms?: number) => {
fn()
return 0 as unknown as ReturnType<typeof setTimeout>
return unsafeTestValue<ReturnType<typeof setTimeout>>(0)
}) as typeof setTimeout
try {
+1 -1
View File
@@ -1,4 +1,4 @@
import { spawn } from "bun"
import { spawn } from "../../shared/bun-spawn-shim"
import { createRequire } from "module"
import { dirname, join } from "path"
import { existsSync } from "fs"
@@ -0,0 +1,51 @@
import { describe, expect, it, mock, afterAll } from "bun:test"
const startPendingCallCleanup = mock(() => {})
const initializeCommentCheckerCli = mock(() => {})
mock.module("./cli-runner", () => ({
initializeCommentCheckerCli,
getCommentCheckerCliPathPromise: () => Promise.resolve("/tmp/fake-comment-checker"),
isCliPathUsable: () => true,
processWithCli: async () => {},
processApplyPatchEditsWithCli: async () => {},
}))
mock.module("./pending-calls", () => ({
registerPendingCall: () => {},
startPendingCallCleanup,
stopPendingCallCleanup: () => {},
takePendingCall: () => undefined,
}))
afterAll(() => {
mock.restore()
})
const { createCommentCheckerHooks } = await import("./hook")
describe("comment-checker lazy initialization", () => {
it("initializes CLI and cleanup on first tool hook call only", async () => {
// given
const hooks = createCommentCheckerHooks()
const beforeHook = hooks["tool.execute.before"]
const input = { tool: "write", sessionID: "ses_test", callID: "call_test" }
const output = { args: { filePath: "src/a.ts" } }
// when
expect(startPendingCallCleanup).toHaveBeenCalledTimes(0)
expect(initializeCommentCheckerCli).toHaveBeenCalledTimes(0)
// then
await beforeHook(input, output)
expect(startPendingCallCleanup).toHaveBeenCalledTimes(1)
expect(initializeCommentCheckerCli).toHaveBeenCalledTimes(1)
// when
await beforeHook(input, output)
// then
expect(startPendingCallCleanup).toHaveBeenCalledTimes(1)
expect(initializeCommentCheckerCli).toHaveBeenCalledTimes(1)
})
})
+6 -3
View File
@@ -28,6 +28,7 @@ import {
stopPendingCallCleanup,
takePendingCall,
} from "./pending-calls"
import { ensureCommentCheckerInitialization } from "./initialization-gate"
import * as fs from "fs"
import { tmpdir } from "os"
@@ -48,14 +49,16 @@ function debugLog(...args: unknown[]) {
export function createCommentCheckerHooks(config?: CommentCheckerConfig) {
debugLog("createCommentCheckerHooks called", { config })
startPendingCallCleanup()
initializeCommentCheckerCli(debugLog)
return {
"tool.execute.before": async (
input: { tool: string; sessionID: string; callID: string },
output: { args: Record<string, unknown> },
): Promise<void> => {
ensureCommentCheckerInitialization(() => {
startPendingCallCleanup()
initializeCommentCheckerCli(debugLog)
})
debugLog("tool.execute.before:", {
tool: input.tool,
callID: input.callID,
@@ -0,0 +1,7 @@
let initialized = false
export function ensureCommentCheckerInitialization(initializer: () => void): void {
if (initialized) return
initialized = true
initializer()
}
+12 -11
View File
@@ -1,4 +1,5 @@
import { describe, test, expect } from "bun:test"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
describe("pending-calls cleanup interval", () => {
test("starts cleanup once and unrefs timer", async () => {
@@ -7,18 +8,18 @@ describe("pending-calls cleanup interval", () => {
const setIntervalCalls: number[] = []
let unrefCalled = 0
globalThis.setInterval = ((
globalThis.setInterval = unsafeTestValue<typeof setInterval>(((
_handler: TimerHandler,
timeout?: number,
..._args: any[]
..._args: unknown[]
) => {
setIntervalCalls.push(timeout as number)
return {
return unsafeTestValue<ReturnType<typeof setInterval>>({
unref: () => {
unrefCalled += 1
},
} as unknown as ReturnType<typeof setInterval>
}) as unknown as typeof setInterval
})
}))
try {
const modulePath = new URL("./pending-calls.ts", import.meta.url).pathname
@@ -43,20 +44,20 @@ describe("pending-calls cleanup interval", () => {
let intervalHandle: ReturnType<typeof setInterval> | undefined
let clearCalls = 0
globalThis.setInterval = ((
globalThis.setInterval = unsafeTestValue<typeof setInterval>(((
_handler: TimerHandler,
_timeout?: number,
..._args: any[]
..._args: unknown[]
) => {
intervalHandle = { unref: () => {} } as unknown as ReturnType<typeof setInterval>
intervalHandle = unsafeTestValue<ReturnType<typeof setInterval>>({ unref: () => {} })
return intervalHandle
}) as unknown as typeof setInterval
}))
globalThis.clearInterval = ((handle?: ReturnType<typeof setInterval>) => {
globalThis.clearInterval = unsafeTestValue<typeof clearInterval>(((handle?: ReturnType<typeof setInterval>) => {
if (handle === intervalHandle) {
clearCalls += 1
}
}) as unknown as typeof clearInterval
}))
try {
const modulePath = new URL("./pending-calls.ts", import.meta.url).pathname
+15 -5
View File
@@ -3,6 +3,7 @@ import {
clearCompactionAgentConfigCheckpoint,
setCompactionAgentConfigCheckpoint,
} from "../../shared/compaction-agent-config-checkpoint"
import { resolveMessageEventSessionID } from "../../shared/event-session-id"
import { log } from "../../shared/logger"
import { COMPACTION_CONTEXT_PROMPT } from "./compaction-context-prompt"
import { resolveSessionPromptConfig } from "./session-prompt-config-resolver"
@@ -35,7 +36,15 @@ export function createCompactionContextInjector(options?: {
const { recoverCheckpointedAgentConfig, maybeWarnAboutNoTextTail } = createRecoveryLogic(ctx, getTailState)
const restore = async (sessionID: string): Promise<boolean> => {
return recoverCheckpointedAgentConfig(sessionID, "compaction.autocontinue")
}
const capture = async (sessionID: string): Promise<void> => {
if (sessionID) {
clearCompactionAgentConfigCheckpoint(sessionID)
}
if (!ctx || !sessionID) {
return
}
@@ -113,14 +122,15 @@ export function createCompactionContextInjector(options?: {
sessionID?: string
} | undefined
if (!info?.sessionID || info.role !== "assistant" || !info.id) {
const sessionID = resolveMessageEventSessionID(props)
if (!sessionID || info?.role !== "assistant" || !info.id) {
return
}
const tailState = getTailState(info.sessionID)
const tailState = getTailState(sessionID)
if (tailState.currentMessageID && tailState.currentMessageID !== info.id) {
finalizeTrackedAssistantMessage(tailState)
await maybeWarnAboutNoTextTail(info.sessionID)
await maybeWarnAboutNoTextTail(sessionID)
}
if (tailState.currentMessageID !== info.id) {
@@ -131,7 +141,7 @@ export function createCompactionContextInjector(options?: {
}
if (event.type === "message.part.delta") {
const sessionID = props?.sessionID as string | undefined
const sessionID = resolveMessageEventSessionID(props)
const messageID = props?.messageID as string | undefined
const field = props?.field as string | undefined
const delta = props?.delta as string | undefined
@@ -160,5 +170,5 @@ export function createCompactionContextInjector(options?: {
}
}
return { capture, inject, event }
return { capture, restore, inject, event }
}
@@ -19,7 +19,26 @@ afterAll(() => {
})
import { createCompactionContextInjector } from "./index"
import type { BackgroundManager } from "../../features/background-agent"
import { TaskHistory } from "../../features/background-agent/task-history"
import { setCompactionAgentConfigCheckpoint } from "../../shared/compaction-agent-config-checkpoint"
type PromptAsyncInput = {
path: { id: string }
body: {
noReply?: boolean
agent?: string
model?: { providerID: string; modelID: string }
tools?: Record<string, boolean | "allow" | "deny" | "ask">
parts: Array<{
type: "text"
text: string
synthetic?: true
metadata?: { compaction_continue?: true }
}>
}
query?: { directory: string }
}
function createMockContext(
messageResponses: Array<Array<{ info?: Record<string, unknown> }>>,
@@ -42,6 +61,10 @@ function createMockContext(
}
}
function createMockBackgroundManager(): BackgroundManager {
return { taskHistory: new TaskHistory() } as BackgroundManager
}
describe("createCompactionContextInjector", () => {
describe("Agent Verification State preservation", () => {
it("includes Agent Verification State section in compaction prompt", async () => {
@@ -112,7 +135,7 @@ describe("createCompactionContextInjector", () => {
it("injects actual task history when backgroundManager and sessionID provided", async () => {
//#given
const mockManager = { taskHistory: new TaskHistory() } as any
const mockManager = createMockBackgroundManager()
mockManager.taskHistory.record("ses_parent", { id: "t1", sessionID: "ses_child", agent: "explore", description: "Find patterns", status: "completed", category: "quick" })
const injector = createCompactionContextInjector({ backgroundManager: mockManager })
@@ -128,7 +151,7 @@ describe("createCompactionContextInjector", () => {
it("does not inject task history section when no entries exist", async () => {
//#given
const mockManager = { taskHistory: new TaskHistory() } as any
const mockManager = createMockBackgroundManager()
const injector = createCompactionContextInjector({ backgroundManager: mockManager })
//#when
@@ -142,7 +165,7 @@ describe("createCompactionContextInjector", () => {
describe("agent checkpoint recovery", () => {
it("re-injects checkpointed agent config after compaction when latest agent is lost", async () => {
//#given
const promptAsyncMock = mock(async () => ({}))
const promptAsyncMock = mock(async (_input: PromptAsyncInput) => ({}))
const ctx = createMockContext(
[
[
@@ -164,12 +187,22 @@ describe("createCompactionContextInjector", () => {
},
},
],
[
{
info: {
role: "user",
agent: "compaction",
model: { providerID: "anthropic", modelID: "claude-opus-4-1" },
},
},
],
[
{
info: {
role: "user",
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
tools: { bash: true },
},
},
],
@@ -185,27 +218,110 @@ describe("createCompactionContextInjector", () => {
})
//#then
expect(promptAsyncMock).toHaveBeenCalledWith({
path: { id: "ses_checkpoint" },
body: {
noReply: true,
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
tools: { bash: true },
parts: [
const recoveryCall = promptAsyncMock.mock.calls[0]?.[0]
expect(recoveryCall?.path).toEqual({ id: "ses_checkpoint" })
expect(recoveryCall?.body.noReply).toBe(true)
expect(recoveryCall?.body.agent).toBe("atlas")
expect(recoveryCall?.body.model).toEqual({ providerID: "openai", modelID: "gpt-5" })
expect(recoveryCall?.body.tools).toEqual({ bash: true })
expect(recoveryCall?.body.parts[0]?.type).toBe("text")
expect(recoveryCall?.body.parts[0]?.text).toContain("restore checkpointed session agent configuration")
expect(recoveryCall?.body.parts[0]?.synthetic).toBe(true)
expect(recoveryCall?.body.parts[0]?.metadata).toEqual({ compaction_continue: true })
expect(recoveryCall?.query).toEqual({ directory: "/tmp/test" })
})
it("re-injects checkpointed agent config during autocontinue before synthetic continue", async () => {
//#given
const promptAsyncMock = mock(async (_input: PromptAsyncInput) => ({}))
const ctx = createMockContext(
[
[
{
type: "text",
text: expect.stringContaining("restore checkpointed session agent configuration"),
info: {
role: "user",
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
tools: { bash: "allow" },
},
},
],
},
query: { directory: "/tmp/test" },
[
{
info: {
role: "user",
agent: "compaction",
model: { providerID: "anthropic", modelID: "claude-opus-4-1" },
},
},
],
[
{
info: {
role: "user",
agent: "compaction",
model: { providerID: "anthropic", modelID: "claude-opus-4-1" },
},
},
],
[
{
info: {
role: "user",
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
tools: { bash: true },
},
},
],
],
promptAsyncMock,
)
const injector = createCompactionContextInjector({ ctx })
//#when
await injector.capture("ses_autocontinue_checkpoint")
const restored = await injector.restore("ses_autocontinue_checkpoint")
//#then
expect(restored).toBe(true)
const recoveryCall = promptAsyncMock.mock.calls[0]?.[0]
expect(recoveryCall?.path).toEqual({ id: "ses_autocontinue_checkpoint" })
expect(recoveryCall?.body.noReply).toBe(true)
expect(recoveryCall?.body.agent).toBe("atlas")
expect(recoveryCall?.body.model).toEqual({ providerID: "openai", modelID: "gpt-5" })
expect(recoveryCall?.body.tools).toEqual({ bash: true })
expect(recoveryCall?.body.parts[0]?.type).toBe("text")
expect(recoveryCall?.body.parts[0]?.text).toContain("restore checkpointed session agent configuration")
expect(recoveryCall?.body.parts[0]?.synthetic).toBe(true)
expect(recoveryCall?.body.parts[0]?.metadata).toEqual({ compaction_continue: true })
expect(recoveryCall?.query).toEqual({ directory: "/tmp/test" })
})
it("clears stale checkpoint when the next compaction capture has no prompt config", async () => {
//#given
const promptAsyncMock = mock(async () => ({}))
const sessionID = "ses_empty_checkpoint_capture"
setCompactionAgentConfigCheckpoint(sessionID, {
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
tools: { bash: true },
})
const ctx = createMockContext([[], [], []], promptAsyncMock)
const injector = createCompactionContextInjector({ ctx })
//#when
await injector.capture(sessionID)
const restored = await injector.restore(sessionID)
//#then
expect(restored).toBe(false)
expect(promptAsyncMock).not.toHaveBeenCalled()
})
it("recovers after five consecutive assistant messages with no text", async () => {
//#given
const promptAsyncMock = mock(async () => ({}))
const promptAsyncMock = mock(async (_input: PromptAsyncInput) => ({}))
const ctx = createMockContext(
[
[
@@ -266,15 +382,10 @@ describe("createCompactionContextInjector", () => {
//#then
expect(promptAsyncMock).toHaveBeenCalledTimes(1)
expect(promptAsyncMock).toHaveBeenCalledWith(
expect.objectContaining({
path: { id: "ses_no_text_tail" },
body: expect.objectContaining({
noReply: true,
agent: "atlas",
}),
}),
)
const recoveryCall = promptAsyncMock.mock.calls[0]?.[0]
expect(recoveryCall?.path).toEqual({ id: "ses_no_text_tail" })
expect(recoveryCall?.body.noReply).toBe(true)
expect(recoveryCall?.body.agent).toBe("atlas")
})
})
})
@@ -15,7 +15,12 @@ type PromptAsyncInput = {
agent?: string
model?: { providerID: string; modelID: string }
tools?: Record<string, boolean>
parts: Array<{ type: "text"; text: string }>
parts: Array<{
type: "text"
text: string
synthetic?: true
metadata?: { compaction_continue?: true }
}>
}
query?: { directory: string }
}
@@ -96,46 +101,31 @@ describe("createCompactionContextInjector recovery", () => {
it("re-injects after compaction when agent and model match but tools are missing", async () => {
//#given
const promptAsyncRecorder = createPromptAsyncRecorder()
const checkpointedPromptConfig = [
{
info: {
role: "user",
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
tools: { bash: true },
},
},
]
const incompletePromptConfig = [
{
info: {
role: "user",
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
},
},
]
const ctx = createMockContext(
[
[
{
info: {
role: "user",
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
tools: { bash: true },
},
},
],
[
{
info: {
role: "user",
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
},
},
],
[
{
info: {
role: "user",
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
},
},
],
[
{
info: {
role: "user",
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
tools: { bash: true },
},
},
],
checkpointedPromptConfig,
incompletePromptConfig,
incompletePromptConfig,
checkpointedPromptConfig,
],
promptAsyncRecorder.promptAsync,
)
@@ -157,6 +147,55 @@ describe("createCompactionContextInjector recovery", () => {
expect(promptAsyncRecorder.calls[0]?.body.tools).toEqual({ bash: true })
})
it("marks the recovery prompt as synthetic compaction continuation", async () => {
//#given
const promptAsyncRecorder = createPromptAsyncRecorder()
const incompletePromptConfig = [
{
info: {
role: "user",
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
},
},
]
const recoveredPromptConfig = [
{
info: {
role: "user",
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
tools: { bash: true },
},
},
]
const ctx = createMockContext(
[
recoveredPromptConfig,
incompletePromptConfig,
incompletePromptConfig,
recoveredPromptConfig,
],
promptAsyncRecorder.promptAsync,
)
const injector = createCompactionContextInjector({ ctx })
//#when
await injector.capture("ses_synthetic_recovery")
await injector.event({
event: {
type: "session.compacted",
properties: { sessionID: "ses_synthetic_recovery" },
},
})
//#then
expect(promptAsyncRecorder.calls.length).toBe(1)
const recoveryPart = promptAsyncRecorder.calls[0]?.body.parts[0]
expect(recoveryPart?.synthetic).toBe(true)
expect(recoveryPart?.metadata).toEqual({ compaction_continue: true })
})
it("retries recovery when the recovered prompt config still mismatches expected model or tools", async () => {
//#given
const promptAsyncRecorder = createPromptAsyncRecorder()
@@ -5,7 +5,7 @@ import {
import {
getCompactionAgentConfigCheckpoint,
} from "../../shared/compaction-agent-config-checkpoint"
import { createInternalAgentTextPart } from "../../shared/internal-initiator-marker"
import { createInternalAgentContinuationTextPart } from "../../shared/internal-initiator-marker"
import { log } from "../../shared/logger"
import { setSessionModel } from "../../shared/session-model-state"
import { setSessionTools } from "../../shared/session-tools-store"
@@ -21,6 +21,7 @@ import {
import { AGENT_RECOVERY_PROMPT, NO_TEXT_TAIL_THRESHOLD, RECOVERY_COOLDOWN_MS, RECENT_COMPACTION_WINDOW_MS } from "./constants"
import type { CompactionContextClient } from "./types"
import type { TailMonitorState } from "./tail-monitor"
import { promptAsyncAfterSessionIdle, releasePromptAsyncReservation } from "../shared/prompt-async-gate"
export function createRecoveryLogic(
ctx: CompactionContextClient | undefined,
@@ -28,7 +29,7 @@ export function createRecoveryLogic(
) {
const recoverCheckpointedAgentConfig = async (
sessionID: string,
reason: "session.compacted" | "no-text-tail",
reason: "compaction.autocontinue" | "session.compacted" | "no-text-tail",
): Promise<boolean> => {
if (!ctx) {
return false
@@ -73,7 +74,7 @@ export function createRecoveryLogic(
const model = expectedPromptConfig.model
const tools = expectedPromptConfig.tools
if (reason === "session.compacted") {
if (reason === "compaction.autocontinue" || reason === "session.compacted") {
const latestPromptConfig = await resolveLatestSessionPromptConfig(ctx, sessionID)
if (isPromptConfigRecovered(latestPromptConfig, expectedPromptConfig)) {
return false
@@ -81,17 +82,30 @@ export function createRecoveryLogic(
}
try {
await ctx.client.session.promptAsync({
path: { id: sessionID },
body: {
noReply: true,
agent: launchAgent ?? expectedPromptConfig.agent,
...(model ? { model } : {}),
...(tools ? { tools } : {}),
parts: [createInternalAgentTextPart(AGENT_RECOVERY_PROMPT)],
const promptResult = await promptAsyncAfterSessionIdle({
client: ctx.client,
sessionID,
source: "compaction-context-injector",
input: {
path: { id: sessionID },
body: {
noReply: true,
agent: launchAgent ?? expectedPromptConfig.agent,
...(model ? { model } : {}),
...(tools ? { tools } : {}),
parts: [createInternalAgentContinuationTextPart(AGENT_RECOVERY_PROMPT)],
},
query: { directory: ctx.directory },
},
query: { directory: ctx.directory },
})
if (promptResult.status !== "dispatched") {
log(`[compaction-context-injector] Recovery skipped by promptAsync gate`, {
sessionID,
reason,
status: promptResult.status,
})
return false
}
const recoveredPromptConfig = await resolveLatestSessionPromptConfig(ctx, sessionID)
if (!isPromptConfigRecovered(recoveredPromptConfig, expectedPromptConfig)) {
@@ -103,6 +117,9 @@ export function createRecoveryLogic(
hasTools: !!tools,
recoveredPromptConfig,
})
releasePromptAsyncReservation(sessionID, "compaction-context-injector:incomplete-recovery", {
reservedBy: "compaction-context-injector",
})
return false
}
@@ -1,8 +1,9 @@
import { resolveSessionEventID } from "../../shared/event-session-id"
export function isCompactionAgent(agent: string | undefined): boolean {
return agent?.trim().toLowerCase() === "compaction"
}
export function resolveSessionID(props?: Record<string, unknown>): string | undefined {
return (props?.sessionID ??
(props?.info as { id?: string } | undefined)?.id) as string | undefined
return resolveSessionEventID(props)
}
@@ -1,5 +1,6 @@
export interface CompactionContextInjector {
capture: (sessionID: string) => Promise<void>
restore: (sessionID: string) => Promise<boolean>
inject: (sessionID?: string) => string
event: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
}
@@ -19,6 +20,7 @@ export type CompactionContextClient = {
}
query?: { directory: string }
}) => Promise<unknown>
status?: () => Promise<unknown>
}
}
directory: string
+123 -6
View File
@@ -1,16 +1,29 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { resolveSessionEventID } from "../../shared/event-session-id"
import { log } from "../../shared/logger"
interface TodoSnapshot {
id: string
id?: string
content: string
status: "pending" | "in_progress" | "completed" | "cancelled"
priority?: "low" | "medium" | "high"
}
type TodoWriter = (input: { sessionID: string; todos: TodoSnapshot[] }) => Promise<void>
type ToolExecuteBeforeInput = { tool: string; sessionID: string; callID: string }
type ToolExecuteBeforeOutput = { args: Record<string, unknown> }
const HOOK_NAME = "compaction-todo-preserver"
const ATLAS_BOOTSTRAP_TODOS = [
{
id: "orchestrate-plan",
content: "Complete ALL implementation tasks",
},
{
id: "pass-final-wave",
content: "Pass Final Verification Wave - ALL reviewers APPROVE",
},
] as const
function extractTodos(response: unknown): TodoSnapshot[] {
const payload = response as { data?: unknown }
@@ -23,6 +36,51 @@ function extractTodos(response: unknown): TodoSnapshot[] {
return []
}
function isAtlasBootstrapTodo(todo: TodoSnapshot): boolean {
return ATLAS_BOOTSTRAP_TODOS.some((bootstrapTodo) =>
todo.id === bootstrapTodo.id || todo.content === bootstrapTodo.content
)
}
function hasDetailedTodos(todos: TodoSnapshot[]): boolean {
return todos.some((todo) => !isAtlasBootstrapTodo(todo))
}
function isAtlasBootstrapTodoList(todos: TodoSnapshot[]): boolean {
return todos.length > 0 && todos.every(isAtlasBootstrapTodo)
}
function shouldRestoreOverCurrentTodos(input: {
snapshot: TodoSnapshot[]
currentTodos: TodoSnapshot[]
}): boolean {
if (input.currentTodos.length === 0) return true
if (!isAtlasBootstrapTodoList(input.currentTodos)) return false
return hasDetailedTodos(input.snapshot)
}
function extractTodoArgument(value: unknown): TodoSnapshot[] {
if (Array.isArray(value)) {
return value as TodoSnapshot[]
}
if (typeof value !== "string") {
return []
}
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) ? parsed as TodoSnapshot[] : []
} catch (err) {
log(`[${HOOK_NAME}] Failed to parse todowrite todos`, { error: String(err) })
return []
}
}
function isTodoWriteTool(toolName: string): boolean {
return toolName.trim().toLowerCase() === "todowrite"
}
async function resolveTodoWriter(): Promise<TodoWriter | null> {
try {
const loader = "opencode/session/todo"
@@ -40,29 +98,40 @@ async function resolveTodoWriter(): Promise<TodoWriter | null> {
}
function resolveSessionID(props?: Record<string, unknown>): string | undefined {
return (props?.sessionID ??
(props?.info as { id?: string } | undefined)?.id) as string | undefined
return resolveSessionEventID(props)
}
export interface CompactionTodoPreserver {
capture: (sessionID: string) => Promise<void>
restore: (sessionID: string) => Promise<void>
event: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
"tool.execute.before": (input: ToolExecuteBeforeInput, output: ToolExecuteBeforeOutput) => Promise<void>
}
export function createCompactionTodoPreserverHook(
ctx: PluginInput,
): CompactionTodoPreserver {
const snapshots = new Map<string, TodoSnapshot[]>()
const protectedSnapshots = new Map<string, TodoSnapshot[]>()
const capture = async (sessionID: string): Promise<void> => {
if (!sessionID) return
protectedSnapshots.delete(sessionID)
try {
const response = await ctx.client.session.todo({ path: { id: sessionID } })
const todos = extractTodos(response)
if (todos.length === 0) return
if (todos.length === 0) {
snapshots.delete(sessionID)
return
}
if (!hasDetailedTodos(todos)) {
snapshots.delete(sessionID)
return
}
snapshots.set(sessionID, todos)
log(`[${HOOK_NAME}] Captured todo snapshot`, { sessionID, count: todos.length })
} catch (err) {
snapshots.delete(sessionID)
log(`[${HOOK_NAME}] Failed to capture todos`, { sessionID, error: String(err) })
}
}
@@ -81,14 +150,22 @@ export function createCompactionTodoPreserverHook(
log(`[${HOOK_NAME}] Failed to fetch todos post-compaction`, { sessionID, error: String(err) })
}
if (hasCurrent && currentTodos.length > 0) {
if (hasCurrent && !shouldRestoreOverCurrentTodos({ snapshot, currentTodos })) {
snapshots.delete(sessionID)
if (hasDetailedTodos(currentTodos)) {
protectedSnapshots.set(sessionID, currentTodos)
} else {
protectedSnapshots.delete(sessionID)
}
log(`[${HOOK_NAME}] Skipped restore (todos already present)`, { sessionID, count: currentTodos.length })
return
}
protectedSnapshots.set(sessionID, snapshot)
const writer = await resolveTodoWriter()
if (!writer) {
snapshots.delete(sessionID)
log(`[${HOOK_NAME}] Skipped restore (Todo.update unavailable)`, { sessionID })
return
}
@@ -110,6 +187,16 @@ export function createCompactionTodoPreserverHook(
const sessionID = resolveSessionID(props)
if (sessionID) {
snapshots.delete(sessionID)
protectedSnapshots.delete(sessionID)
}
return
}
if (event.type === "session.idle") {
const sessionID = resolveSessionID(props)
if (sessionID) {
snapshots.delete(sessionID)
protectedSnapshots.delete(sessionID)
}
return
}
@@ -123,5 +210,35 @@ export function createCompactionTodoPreserverHook(
}
}
return { capture, event }
const beforeToolExecute = async (
input: ToolExecuteBeforeInput,
output: ToolExecuteBeforeOutput,
): Promise<void> => {
if (!isTodoWriteTool(input.tool)) {
return
}
const snapshot = protectedSnapshots.get(input.sessionID)
if (!snapshot || !hasDetailedTodos(snapshot)) {
return
}
const requestedTodos = extractTodoArgument(output.args.todos)
if (requestedTodos.length === 0) {
return
}
if (!isAtlasBootstrapTodoList(requestedTodos)) {
protectedSnapshots.delete(input.sessionID)
return
}
output.args.todos = snapshot
log(`[${HOOK_NAME}] Replaced late Atlas bootstrap todowrite with restored snapshot`, {
sessionID: input.sessionID,
count: snapshot.length,
})
}
return { capture, restore, event, "tool.execute.before": beforeToolExecute }
}
@@ -1,17 +1,24 @@
import { describe, expect, it, afterAll, mock } from "bun:test"
import { describe, expect, it, afterAll, beforeEach, mock } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import { createOpencodeClient } from "@opencode-ai/sdk"
import type { Todo } from "@opencode-ai/sdk"
import { createCompactionTodoPreserverHook } from "./index"
const updateMock = mock(async () => {})
let todoWriter: typeof updateMock | undefined = updateMock
mock.module("opencode/session/todo", () => ({
Todo: {
update: updateMock,
get update() {
return todoWriter
},
},
}))
beforeEach(() => {
todoWriter = updateMock
})
afterAll(() => {
mock.module("opencode/session/todo", () => ({
Todo: {
@@ -21,7 +28,9 @@ afterAll(() => {
mock.restore()
})
function createMockContext(todoResponses: Array<Todo>[]): PluginInput {
type TodoResponse = Todo[] | Error
function createMockContext(todoResponses: TodoResponse[]): PluginInput {
let callIndex = 0
const client = createOpencodeClient({ directory: "/tmp/test" })
@@ -33,6 +42,9 @@ function createMockContext(todoResponses: Array<Todo>[]): PluginInput {
client.session.todo = mock((_: SessionTodoOptions): SessionTodoResult => {
const current = todoResponses[Math.min(callIndex, todoResponses.length - 1)] ?? []
callIndex += 1
if (current instanceof Error) {
return Promise.reject(current)
}
return Promise.resolve({ data: current, error: undefined, request, response })
})
@@ -52,8 +64,8 @@ describe("compaction-todo-preserver", () => {
updateMock.mockClear()
const sessionID = "session-compaction-missing"
const todos: Todo[] = [
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
{ id: "2", content: "Task 2", status: "in_progress", priority: "medium" },
{ content: "Task 1", status: "pending", priority: "high" },
{ content: "Task 2", status: "in_progress", priority: "medium" },
]
const ctx = createMockContext([todos, []])
const hook = createCompactionTodoPreserverHook(ctx)
@@ -72,7 +84,7 @@ describe("compaction-todo-preserver", () => {
updateMock.mockClear()
const sessionID = "session-compaction-present"
const todos: Todo[] = [
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
{ content: "Task 1", status: "pending", priority: "high" },
]
const ctx = createMockContext([todos, todos])
const hook = createCompactionTodoPreserverHook(ctx)
@@ -84,4 +96,227 @@ describe("compaction-todo-preserver", () => {
//#then
expect(updateMock).not.toHaveBeenCalled()
})
it("restores detailed todos when only Atlas bootstrap todos are present after compaction", async () => {
//#given
updateMock.mockClear()
const sessionID = "session-compaction-atlas-bootstrap"
const detailedTodos: Todo[] = [
{ content: "Inspect runtime compaction state", status: "completed", priority: "high" },
{ content: "Add regression coverage for todo preservation", status: "in_progress", priority: "high" },
{ content: "Run focused tests and open PR", status: "pending", priority: "medium" },
]
const atlasBootstrapTodos: Todo[] = [
{ content: "Complete ALL implementation tasks", status: "in_progress", priority: "high" },
{ content: "Pass Final Verification Wave - ALL reviewers APPROVE", status: "pending", priority: "high" },
]
const ctx = createMockContext([detailedTodos, atlasBootstrapTodos])
const hook = createCompactionTodoPreserverHook(ctx)
//#when
await hook.capture(sessionID)
await hook.event({ event: { type: "session.compacted", properties: { sessionID } } })
//#then
expect(updateMock).toHaveBeenCalledTimes(1)
expect(updateMock).toHaveBeenCalledWith({ sessionID, todos: detailedTodos })
})
it("skips restore when current todos include meaningful post-compaction work", async () => {
//#given
updateMock.mockClear()
const sessionID = "session-compaction-meaningful-current"
const detailedTodos: Todo[] = [
{ content: "Inspect runtime compaction state", status: "completed", priority: "high" },
{ content: "Add regression coverage for todo preservation", status: "in_progress", priority: "high" },
]
const currentTodos: Todo[] = [
{ content: "Complete ALL implementation tasks", status: "in_progress", priority: "high" },
{ content: "Review post-compaction findings", status: "pending", priority: "medium" },
]
const ctx = createMockContext([detailedTodos, currentTodos])
const hook = createCompactionTodoPreserverHook(ctx)
//#when
await hook.capture(sessionID)
await hook.event({ event: { type: "session.compacted", properties: { sessionID } } })
//#then
expect(updateMock).not.toHaveBeenCalled()
})
it("does not restore a stale snapshot after a later empty capture", async () => {
//#given
updateMock.mockClear()
const sessionID = "session-compaction-empty-later"
const oldTodos: Todo[] = [
{ content: "Old task that no longer exists", status: "pending", priority: "high" },
]
const ctx = createMockContext([oldTodos, []])
const hook = createCompactionTodoPreserverHook(ctx)
//#when
await hook.capture(sessionID)
await hook.capture(sessionID)
await hook.event({ event: { type: "session.compacted", properties: { sessionID } } })
//#then
expect(updateMock).not.toHaveBeenCalled()
})
it("does not restore a stale snapshot after a later failed capture", async () => {
//#given
updateMock.mockClear()
const sessionID = "session-compaction-failed-later"
const oldTodos: Todo[] = [
{ content: "Old task that should not come back", status: "pending", priority: "high" },
]
const ctx = createMockContext([oldTodos, new Error("todo api unavailable")])
const hook = createCompactionTodoPreserverHook(ctx)
//#when
await hook.capture(sessionID)
await hook.capture(sessionID)
await hook.event({ event: { type: "session.compacted", properties: { sessionID } } })
//#then
expect(updateMock).not.toHaveBeenCalled()
})
it("does not retain a stale snapshot when Todo.update is unavailable", async () => {
//#given
updateMock.mockClear()
const sessionID = "session-compaction-writer-unavailable"
const detailedTodos: Todo[] = [
{ content: "Detailed task before missing writer", status: "in_progress", priority: "high" },
]
const ctx = createMockContext([detailedTodos, [], []])
const hook = createCompactionTodoPreserverHook(ctx)
//#when
await hook.capture(sessionID)
todoWriter = undefined
await hook.restore(sessionID)
todoWriter = updateMock
await hook.event({ event: { type: "session.compacted", properties: { sessionID } } })
//#then
expect(updateMock).not.toHaveBeenCalled()
})
it("does not preserve Atlas bootstrap todos when they are the only pre-compaction snapshot", async () => {
//#given
updateMock.mockClear()
const sessionID = "session-compaction-bootstrap-only-snapshot"
const atlasBootstrapTodos: Todo[] = [
{ content: "Complete ALL implementation tasks", status: "in_progress", priority: "high" },
{ content: "Pass Final Verification Wave - ALL reviewers APPROVE", status: "pending", priority: "high" },
]
const ctx = createMockContext([atlasBootstrapTodos, []])
const hook = createCompactionTodoPreserverHook(ctx)
//#when
await hook.capture(sessionID)
await hook.event({ event: { type: "session.compacted", properties: { sessionID } } })
//#then
expect(updateMock).not.toHaveBeenCalled()
})
it("preserves restored detailed todos when Atlas writes bootstrap todos after compaction", async () => {
//#given
updateMock.mockClear()
const sessionID = "session-compaction-late-atlas-bootstrap"
const detailedTodos: Todo[] = [
{ content: "Inspect runtime compaction state", status: "completed", priority: "high" },
{ content: "Add regression coverage for todo preservation", status: "in_progress", priority: "high" },
{ content: "Run focused tests and open PR", status: "pending", priority: "medium" },
]
const atlasBootstrapTodos: Todo[] = [
{ content: "Complete ALL implementation tasks", status: "in_progress", priority: "high" },
{ content: "Pass Final Verification Wave - ALL reviewers APPROVE", status: "pending", priority: "high" },
]
const ctx = createMockContext([detailedTodos, []])
const hook = createCompactionTodoPreserverHook(ctx)
const output = { args: { todos: atlasBootstrapTodos } }
//#when
await hook.capture(sessionID)
await hook.event({ event: { type: "session.compacted", properties: { sessionID } } })
await hook["tool.execute.before"]({ tool: "todowrite", sessionID, callID: "call-bootstrap" }, output)
//#then
expect(updateMock).toHaveBeenCalledWith({ sessionID, todos: detailedTodos })
expect(output.args.todos).toEqual(detailedTodos)
})
it("protects detailed current todos from a later Atlas bootstrap write after compaction", async () => {
//#given
updateMock.mockClear()
const sessionID = "session-compaction-detailed-current-late-bootstrap"
const detailedTodos: Todo[] = [
{ content: "Keep detailed task one", status: "in_progress", priority: "high" },
{ content: "Keep detailed task two", status: "pending", priority: "medium" },
]
const atlasBootstrapTodos: Todo[] = [
{ content: "Complete ALL implementation tasks", status: "in_progress", priority: "high" },
{ content: "Pass Final Verification Wave - ALL reviewers APPROVE", status: "pending", priority: "high" },
]
const ctx = createMockContext([detailedTodos, detailedTodos])
const hook = createCompactionTodoPreserverHook(ctx)
const output = { args: { todos: atlasBootstrapTodos } }
//#when
await hook.capture(sessionID)
await hook.restore(sessionID)
await hook["tool.execute.before"]({ tool: "todowrite", sessionID, callID: "call-bootstrap" }, output)
//#then
expect(updateMock).not.toHaveBeenCalled()
expect(output.args.todos).toEqual(detailedTodos)
})
it("clears late bootstrap protection when the session idles", async () => {
//#given
updateMock.mockClear()
const sessionID = "session-compaction-protection-idle"
const detailedTodos: Todo[] = [
{ content: "Detailed task before idle", status: "in_progress", priority: "high" },
]
const atlasBootstrapTodos: Todo[] = [
{ content: "Complete ALL implementation tasks", status: "in_progress", priority: "high" },
{ content: "Pass Final Verification Wave - ALL reviewers APPROVE", status: "pending", priority: "high" },
]
const ctx = createMockContext([detailedTodos, detailedTodos])
const hook = createCompactionTodoPreserverHook(ctx)
const output = { args: { todos: atlasBootstrapTodos } }
//#when
await hook.capture(sessionID)
await hook.restore(sessionID)
await hook.event({ event: { type: "session.idle", properties: { sessionID } } })
await hook["tool.execute.before"]({ tool: "todowrite", sessionID, callID: "call-bootstrap" }, output)
//#then
expect(output.args.todos).toEqual(atlasBootstrapTodos)
})
it("clears a pending snapshot when the session idles before restore", async () => {
//#given
updateMock.mockClear()
const sessionID = "session-compaction-idle-before-restore"
const detailedTodos: Todo[] = [
{ content: "Detailed task before interrupted compaction", status: "in_progress", priority: "high" },
]
const ctx = createMockContext([detailedTodos, []])
const hook = createCompactionTodoPreserverHook(ctx)
//#when
await hook.capture(sessionID)
await hook.event({ event: { type: "session.idle", properties: { sessionID } } })
await hook.event({ event: { type: "session.compacted", properties: { sessionID } } })
//#then
expect(updateMock).not.toHaveBeenCalled()
})
})
+95
View File
@@ -143,6 +143,62 @@ describe("context-window-monitor", () => {
expect(ctx.client.session.messages).not.toHaveBeenCalled()
})
// #given total input tokens exceed the resolved actualLimit (e.g. 1M-context
// Anthropic model where resolveActualContextLimit falls back to the
// 200K default for the model family)
// #when tool.execute.after appends the context status block
// #then the displayed used% must be clamped to 100 and remaining% must not go
// negative. Safety-tuned models flag the >100% / negative-remaining
// block as prompt injection (issue #3655).
it("should clamp displayed percentages when input exceeds actualLimit (regression #3655)", async () => {
const hook = createContextWindowMonitorHook(ctx as never)
const sessionID = "ses_overflow"
// 289,370 input + 0 cache against a 200K resolved limit -> 144.7% raw,
// -44.7% remaining if not clamped.
await hook.event({
event: {
type: "message.updated",
properties: {
info: {
role: "assistant",
sessionID,
providerID: "anthropic",
finish: true,
tokens: {
input: 289370,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
},
},
},
})
const output = { title: "", output: "original", metadata: null }
await hook["tool.execute.after"](
{ tool: "bash", sessionID, callID: "call_1" },
output
)
// The block must still be emitted (we are above the 70% threshold).
expect(output.output).toContain("[Context Status:")
// Extract the displayed percentages and assert clamping.
const match = output.output.match(
/\[Context Status: ([\d.-]+)% used \([\d,]+\/[\d,]+ tokens\), ([\d.-]+)% remaining\]/,
)
expect(match).not.toBeNull()
const usedPct = Number(match![1])
const remainingPct = Number(match![2])
expect(usedPct).toBeLessThanOrEqual(100)
expect(usedPct).toBeGreaterThanOrEqual(0)
expect(remainingPct).toBeGreaterThanOrEqual(0)
expect(remainingPct).toBeLessThanOrEqual(100)
})
it("should append context reminder for google-vertex-anthropic provider", async () => {
//#given cached usage for google-vertex-anthropic above threshold
const hook = createContextWindowMonitorHook(ctx as never)
@@ -179,6 +235,45 @@ describe("context-window-monitor", () => {
expect(output.output).toContain("context remaining")
})
// #given only a compaction agent summary message update is seen
// #when tool.execute.after checks context usage
// #then stale pre-compaction tokens should not create a context reminder
it("should ignore compaction-agent message updates when caching context usage", async () => {
const hook = createContextWindowMonitorHook(ctx as never)
const sessionID = "ses_compaction_agent_context"
await hook.event({
event: {
type: "message.updated",
properties: {
info: {
agent: "compaction",
role: "assistant",
sessionID,
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
finish: true,
tokens: {
input: 150000,
output: 1000,
reasoning: 0,
cache: { read: 10000, write: 0 },
},
},
},
},
})
const output = { title: "", output: "original", metadata: null }
await hook["tool.execute.after"](
{ tool: "bash", sessionID, callID: "call_1" },
output
)
expect(output.output).toBe("original")
expect(ctx.client.session.messages).not.toHaveBeenCalled()
})
// #given session is deleted
// #when session.deleted event fires
// #then cached data should be cleaned up
+20 -8
View File
@@ -3,6 +3,8 @@ import {
resolveActualContextLimit,
type ContextLimitModelCacheState,
} from "../shared/context-limit-resolver"
import { isCompactionAgent } from "../shared/compaction-marker"
import { resolveMessageEventSessionID, resolveSessionEventID } from "../shared/event-session-id"
import { createSystemDirective, SystemDirectiveTypes } from "../shared/system-directive"
const CONTEXT_WARNING_THRESHOLD = 0.70
@@ -65,8 +67,15 @@ export function createContextWindowMonitorHook(
remindedSessions.add(sessionID)
const usedPct = (actualUsagePercentage * 100).toFixed(1)
const remainingPct = ((1 - actualUsagePercentage) * 100).toFixed(1)
// Clamp the displayed percentages so the block stays trustworthy when the
// resolved actualLimit underestimates the model's real context window
// (e.g. a 1M-context Anthropic model that falls back to the 200K default).
// Without clamping, the block would advertise >100% used and a negative
// "remaining" - safety-tuned models flag exactly that pattern as a prompt
// injection and refuse to follow the directive (issue #3655).
const clampedPercentage = Math.min(Math.max(actualUsagePercentage, 0), 1)
const usedPct = (clampedPercentage * 100).toFixed(1)
const remainingPct = ((1 - clampedPercentage) * 100).toFixed(1)
const usedTokens = totalInputTokens.toLocaleString()
const limitTokens = actualLimit.toLocaleString()
@@ -78,15 +87,16 @@ export function createContextWindowMonitorHook(
const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id) {
remindedSessions.delete(sessionInfo.id)
tokenCache.delete(sessionInfo.id)
const sessionID = resolveSessionEventID(props)
if (sessionID) {
remindedSessions.delete(sessionID)
tokenCache.delete(sessionID)
}
}
if (event.type === "message.updated") {
const info = props?.info as {
agent?: unknown
role?: string
sessionID?: string
providerID?: string
@@ -96,9 +106,11 @@ export function createContextWindowMonitorHook(
} | undefined
if (!info || info.role !== "assistant" || !info.finish) return
if (!info.sessionID || !info.providerID || !info.tokens) return
if (isCompactionAgent(info.agent)) return
const sessionID = resolveMessageEventSessionID(props)
if (!sessionID || !info.providerID || !info.tokens) return
tokenCache.set(info.sessionID, {
tokenCache.set(sessionID, {
providerID: info.providerID,
modelID: info.modelID ?? "",
tokens: info.tokens,
@@ -1,4 +1,4 @@
import { existsSync } from "node:fs";
import { constants, promises as fsPromises } from "node:fs";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { AGENTS_FILENAME } from "./constants";
@@ -9,10 +9,10 @@ export function resolveFilePath(rootDirectory: string, path: string): string | n
return resolve(rootDirectory, path);
}
export function findAgentsMdUp(input: {
export async function findAgentsMdUp(input: {
startDir: string;
rootDir: string;
}): string[] {
}): Promise<string[]> {
const found: string[] = [];
let current = input.startDir;
@@ -22,7 +22,11 @@ export function findAgentsMdUp(input: {
const isRootDir = current === input.rootDir;
if (!isRootDir) {
const agentsPath = join(current, AGENTS_FILENAME);
if (existsSync(agentsPath)) {
const exists = await fsPromises
.access(agentsPath, constants.F_OK)
.then(() => true)
.catch(() => false);
if (exists) {
found.push(agentsPath);
}
}
+6 -6
View File
@@ -1,6 +1,7 @@
import type { PluginInput } from "@opencode-ai/plugin";
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
import { resolveSessionEventID } from "../../shared/event-session-id";
import { processFilePathForAgentsInjection } from "./injector";
import { clearInjectedPaths } from "./storage";
@@ -56,16 +57,15 @@ export function createDirectoryAgentsInjectorHook(
const props = event.properties as Record<string, unknown> | undefined;
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined;
if (sessionInfo?.id) {
sessionCaches.delete(sessionInfo.id);
clearInjectedPaths(sessionInfo.id);
const sessionID = resolveSessionEventID(props);
if (sessionID) {
sessionCaches.delete(sessionID);
clearInjectedPaths(sessionID);
}
}
if (event.type === "session.compacted") {
const sessionID = (props?.sessionID ??
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
const sessionID = resolveSessionEventID(props);
if (sessionID) {
sessionCaches.delete(sessionID);
clearInjectedPaths(sessionID);
@@ -84,6 +84,23 @@ describe("processFilePathForAgentsInjection", () => {
expect(output.output).toContain(srcAgentsContent)
})
it("finds AGENTS.md files while walking up directories", async () => {
// given
const { findAgentsMdUp } = await import("./finder")
// when
const agentsPaths = await findAgentsMdUp({
startDir: componentsDirectory,
rootDir: testRoot,
})
// then
expect(agentsPaths).toEqual([
join(srcDirectory, "AGENTS.md"),
join(componentsDirectory, "AGENTS.md"),
])
})
it("skips root-level AGENTS.md", async () => {
// given
rmSync(join(srcDirectory, "AGENTS.md"), { force: true })
@@ -1,5 +1,5 @@
import type { PluginInput } from "@opencode-ai/plugin";
import { readFileSync } from "node:fs";
import { promises as fsPromises } from "node:fs";
import { dirname } from "node:path";
import type { createDynamicTruncator } from "../../shared/dynamic-truncator";
@@ -26,12 +26,16 @@ export async function processFilePathForAgentsInjection(input: {
sessionID: string;
output: { title: string; output: string; metadata: unknown };
}): Promise<void> {
// Guard: output.output may be non-string at runtime (e.g. MCP bridge format changes).
// Consistent with the pattern used in tool-output-truncator and other hooks.
if (typeof input.output.output !== "string") return;
const resolved = resolveFilePath(input.ctx.directory, input.filePath);
if (!resolved) return;
const dir = dirname(resolved);
const cache = getSessionCache(input.sessionCaches, input.sessionID);
const agentsPaths = findAgentsMdUp({ startDir: dir, rootDir: input.ctx.directory });
const agentsPaths = await findAgentsMdUp({ startDir: dir, rootDir: input.ctx.directory });
let dirty = false;
for (const agentsPath of agentsPaths) {
@@ -39,7 +43,8 @@ export async function processFilePathForAgentsInjection(input: {
if (cache.has(agentsDir)) continue;
try {
const content = readFileSync(agentsPath, "utf-8");
const content = await fsPromises.readFile(agentsPath, "utf-8");
cache.add(agentsDir);
const { result, truncated } = await input.truncator.truncate(
input.sessionID,
content,
@@ -48,7 +53,6 @@ export async function processFilePathForAgentsInjection(input: {
? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${agentsPath}]`
: "";
input.output.output += `\n\n[Directory Context: ${agentsPath}]\n${result}${truncationNotice}`;
cache.add(agentsDir);
dirty = true;
} catch {}
}
@@ -1,4 +1,4 @@
import { existsSync } from "node:fs";
import { access } from "node:fs/promises";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { README_FILENAME } from "./constants";
@@ -9,17 +9,19 @@ export function resolveFilePath(rootDirectory: string, path: string): string | n
return resolve(rootDirectory, path);
}
export function findReadmeMdUp(input: {
export async function findReadmeMdUp(input: {
startDir: string;
rootDir: string;
}): string[] {
}): Promise<string[]> {
const found: string[] = [];
let current = input.startDir;
while (true) {
const readmePath = join(current, README_FILENAME);
if (existsSync(readmePath)) {
try {
await access(readmePath);
found.push(readmePath);
} catch {
}
if (current === input.rootDir) break;
+6 -6
View File
@@ -1,6 +1,7 @@
import type { PluginInput } from "@opencode-ai/plugin";
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
import { resolveSessionEventID } from "../../shared/event-session-id";
import { processFilePathForReadmeInjection } from "./injector";
import { clearInjectedPaths } from "./storage";
@@ -56,16 +57,15 @@ export function createDirectoryReadmeInjectorHook(
const props = event.properties as Record<string, unknown> | undefined;
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined;
if (sessionInfo?.id) {
sessionCaches.delete(sessionInfo.id);
clearInjectedPaths(sessionInfo.id);
const sessionID = resolveSessionEventID(props);
if (sessionID) {
sessionCaches.delete(sessionID);
clearInjectedPaths(sessionID);
}
}
if (event.type === "session.compacted") {
const sessionID = (props?.sessionID ??
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
const sessionID = resolveSessionEventID(props);
if (sessionID) {
sessionCaches.delete(sessionID);
clearInjectedPaths(sessionID);
@@ -133,6 +133,32 @@ describe("processFilePathForReadmeInjection", () => {
expect(output.output).toContain("# Components README")
})
it("returns a promise and finds README.md files from temp fixtures", async () => {
// given
const sourceDirectory = join(testRoot, "src")
const componentsDirectory = join(sourceDirectory, "components")
mkdirSync(componentsDirectory, { recursive: true })
writeFileSync(join(testRoot, "README.md"), "# Root README")
writeFileSync(join(sourceDirectory, "README.md"), "# Src README")
writeFileSync(join(componentsDirectory, "README.md"), "# Components README")
const { findReadmeMdUp } = await import("./finder")
// when
const promise = findReadmeMdUp({
startDir: componentsDirectory,
rootDir: testRoot,
})
// then
expect(promise).toBeInstanceOf(Promise)
await expect(promise).resolves.toEqual([
join(testRoot, "README.md"),
join(sourceDirectory, "README.md"),
join(componentsDirectory, "README.md"),
])
})
it("does not re-inject already cached directories", async () => {
// given
const sourceDirectory = join(testRoot, "src")
@@ -1,5 +1,5 @@
import type { PluginInput } from "@opencode-ai/plugin";
import { readFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { dirname } from "node:path";
import type { createDynamicTruncator } from "../../shared/dynamic-truncator";
@@ -31,7 +31,7 @@ export async function processFilePathForReadmeInjection(input: {
const dir = dirname(resolved);
const cache = getSessionCache(input.sessionCaches, input.sessionID);
const readmePaths = findReadmeMdUp({ startDir: dir, rootDir: input.ctx.directory });
const readmePaths = await findReadmeMdUp({ startDir: dir, rootDir: input.ctx.directory });
let dirty = false;
for (const readmePath of readmePaths) {
@@ -39,7 +39,7 @@ export async function processFilePathForReadmeInjection(input: {
if (cache.has(readmeDir)) continue;
try {
const content = readFileSync(readmePath, "utf-8");
const content = await readFile(readmePath, "utf-8");
const { result, truncated } = await input.truncator.truncate(
input.sessionID,
content,
+3 -2
View File
@@ -1,11 +1,12 @@
import { describe, it, expect, beforeEach } from "bun:test"
import { createEditErrorRecoveryHook, EDIT_ERROR_REMINDER, EDIT_ERROR_PATTERNS } from "./index"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
describe("createEditErrorRecoveryHook", () => {
let hook: ReturnType<typeof createEditErrorRecoveryHook>
beforeEach(() => {
hook = createEditErrorRecoveryHook({} as any)
hook = createEditErrorRecoveryHook(unsafeTestValue({}))
})
describe("tool.execute.after", () => {
@@ -108,7 +109,7 @@ describe("createEditErrorRecoveryHook", () => {
const input = createInput("Edit")
const output = {
title: "Edit",
output: undefined as unknown as string,
output: unsafeTestValue<string>(undefined),
metadata: {},
}
@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it } from "bun:test"
import { classifyPathEnvironment } from "../../shared/classify-path-environment"
import { clearAllSkips, recordFsyncSkip } from "../../shared/fsync-skip-tracker"
import { createFsyncSkipWarningHook } from "./index"
describe("createFsyncSkipWarningHook", () => {
beforeEach(() => {
clearAllSkips()
})
it("records callID start timestamp in tool.execute.before", async () => {
const hook = createFsyncSkipWarningHook()
const input = { tool: "bash", sessionID: "ses1", callID: "call-1" }
const output = { args: {} as Record<string, unknown> }
await hook["tool.execute.before"](input, output)
await Bun.sleep(2)
recordFsyncSkip({
filePath: "/tmp/a",
contextLabel: "atomicWrite:/tmp/a",
errorCode: "EPERM",
message: "operation not permitted",
pathClassification: classifyPathEnvironment("/tmp/a"),
})
const afterOutput = { title: "ok", output: "done", metadata: {} as Record<string, unknown> }
await hook["tool.execute.after"](input, afterOutput)
expect(afterOutput.output).toContain("[fsync-skipped]")
})
it("drains skips after start time and appends warning to output text", async () => {
const hook = createFsyncSkipWarningHook()
const input = { tool: "write", sessionID: "ses1", callID: "call-2" }
const beforeOutput = { args: {} as Record<string, unknown> }
const afterOutput = { title: "ok", output: "base", metadata: {} as Record<string, unknown> }
await hook["tool.execute.before"](input, beforeOutput)
await Bun.sleep(2)
recordFsyncSkip({
filePath: "/Users/x/OneDrive/a",
contextLabel: "atomicWrite:/Users/x/OneDrive/a",
errorCode: "EPERM",
message: "operation not permitted",
pathClassification: classifyPathEnvironment("/Users/x/OneDrive/a"),
})
await hook["tool.execute.after"](input, afterOutput)
expect(afterOutput.output).toContain("base\n\n---")
expect(afterOutput.output).toContain("OneDrive")
})
it("leaves output unchanged when no skips happen during window", async () => {
const hook = createFsyncSkipWarningHook()
const input = { tool: "write", sessionID: "ses1", callID: "call-3" }
const beforeOutput = { args: {} as Record<string, unknown> }
const afterOutput = { title: "ok", output: "base", metadata: {} as Record<string, unknown> }
await hook["tool.execute.before"](input, beforeOutput)
await hook["tool.execute.after"](input, afterOutput)
expect(afterOutput.output).toBe("base")
})
it("isolates multiple parallel calls by callID watermark", async () => {
const hook = createFsyncSkipWarningHook()
const beforeOutput = { args: {} as Record<string, unknown> }
const inputA = { tool: "write", sessionID: "ses1", callID: "call-A" }
const inputB = { tool: "write", sessionID: "ses1", callID: "call-B" }
await hook["tool.execute.before"](inputA, beforeOutput)
await Bun.sleep(2)
await hook["tool.execute.before"](inputB, beforeOutput)
await Bun.sleep(2)
recordFsyncSkip({
filePath: "/tmp/a",
contextLabel: "atomicWrite:/tmp/a",
errorCode: "EPERM",
message: "operation not permitted",
pathClassification: classifyPathEnvironment("/tmp/a"),
})
const outputA = { title: "ok", output: "A", metadata: {} as Record<string, unknown> }
const outputB = { title: "ok", output: "B", metadata: {} as Record<string, unknown> }
await hook["tool.execute.after"](inputA, outputA)
await hook["tool.execute.after"](inputB, outputB)
expect(outputA.output).toContain("[fsync-skipped]")
expect(outputB.output).toBe("B")
})
})
+50
View File
@@ -0,0 +1,50 @@
import { drainSkipsAfter } from "../../shared/fsync-skip-tracker"
import { formatFsyncSkipWarning } from "../../shared/fsync-skip-warning-formatter"
type ToolExecuteInput = {
tool: string
sessionID: string
callID: string
}
type ToolBeforeOutput = {
args: Record<string, unknown>
}
type ToolAfterOutput = {
title: string
output: string
metadata: unknown
}
export function createFsyncSkipWarningHook() {
const startTimesByCallId = new Map<string, number>()
const toolExecuteBefore = async (
input: ToolExecuteInput,
_output: ToolBeforeOutput,
): Promise<void> => {
startTimesByCallId.set(input.callID, Date.now())
}
const toolExecuteAfter = async (
input: ToolExecuteInput,
output: ToolAfterOutput,
): Promise<void> => {
if (typeof output.output !== "string") return
const startTimestamp = startTimesByCallId.get(input.callID) ?? 0
startTimesByCallId.delete(input.callID)
const skips = drainSkipsAfter(startTimestamp)
const warning = formatFsyncSkipWarning(skips)
if (warning.length === 0) return
output.output = `${output.output}\n\n${warning}`
}
return {
"tool.execute.before": toolExecuteBefore,
"tool.execute.after": toolExecuteAfter,
}
}
@@ -1,4 +1,5 @@
import { log } from "../../shared"
import { bunFile } from "../../shared/bun-file-shim"
import { generateUnifiedDiff, countLineDiffs } from "../../tools/hashline-edit/diff-utils"
interface HashlineEditDiffEnhancerConfig {
@@ -38,7 +39,7 @@ function extractFilePath(args: Record<string, unknown>): string | undefined {
async function captureOldContent(filePath: string): Promise<string> {
try {
const file = Bun.file(filePath)
const file = bunFile(filePath)
if (await file.exists()) {
return await file.text()
}
@@ -79,7 +80,7 @@ export function createHashlineEditDiffEnhancerHook(config: HashlineEditDiffEnhan
let newContent: string
try {
newContent = await Bun.file(filePath).text()
newContent = await bunFile(filePath).text()
} catch {
log("[hashline-edit-diff-enhancer] failed to read new content", { filePath })
return
+24 -1
View File
@@ -1,4 +1,5 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { bunFile } from "../../shared/bun-file-shim"
import { computeLineHash } from "../../tools/hashline-edit/hash-computation"
const WRITE_SUCCESS_MARKER = "File written successfully."
@@ -141,6 +142,22 @@ function extractFilePath(metadata: unknown): string | undefined {
return undefined
}
function extractLineCount(metadata: unknown): number | undefined {
if (!metadata || typeof metadata !== "object") {
return undefined
}
const objectMeta = metadata as Record<string, unknown>
const candidates = [objectMeta.lineCount, objectMeta.linesWritten, objectMeta.lines]
for (const candidate of candidates) {
if (typeof candidate === "number" && Number.isInteger(candidate) && candidate >= 0) {
return candidate
}
}
return undefined
}
async function appendWriteHashlineOutput(output: { output: string; metadata: unknown }): Promise<void> {
if (output.output.startsWith(WRITE_SUCCESS_MARKER)) {
return
@@ -151,12 +168,18 @@ async function appendWriteHashlineOutput(output: { output: string; metadata: unk
return
}
const metadataLineCount = extractLineCount(output.metadata)
if (metadataLineCount !== undefined) {
output.output = `${WRITE_SUCCESS_MARKER} ${metadataLineCount} lines written.`
return
}
const filePath = extractFilePath(output.metadata)
if (!filePath) {
return
}
const file = Bun.file(filePath)
const file = bunFile(filePath)
if (!(await file.exists())) {
return
}

Some files were not shown because too many files have changed in this diff Show More