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:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+203
@@ -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)
|
||||
})
|
||||
})
|
||||
+54
-9
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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({
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+434
-157
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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")
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
)
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
# src/hooks/claude-code-hooks/ — Claude Code Compatibility
|
||||
|
||||
**Generated:** 2026-04-18
|
||||
**Generated:** 2026-05-15
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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" })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,6 +1,6 @@
|
||||
# src/hooks/comment-checker/ — AI Slop Comment Blocker
|
||||
|
||||
**Generated:** 2026-04-18
|
||||
**Generated:** 2026-05-15
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -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,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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user