merge(dev): resolve background-agent delegated fallback conflicts
Reconcile the latest dev branch changes with the delegated child-session fallback work. Preserve the upstream background-agent updates while keeping the delegated bootstrap cleanup and compatibility wiring fixes intact, then re-verify the affected regression suites and typecheck. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
+132
-162
@@ -1,176 +1,146 @@
|
||||
# src/hooks/ — 52 Lifecycle Hooks
|
||||
# src/hooks/ — ~50 Lifecycle Hooks Across 57 Dirs
|
||||
|
||||
**Generated:** 2026-04-18
|
||||
**Generated:** 2026-05-08
|
||||
|
||||
## 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.
|
||||
50 hooks (7 of the 57 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` | 14 | 15 | 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: **52 base, 59 with team-mode** (counts the 4 team-session-events handlers individually).
|
||||
|
||||
Hook name allowlist for `disabled_hooks`: 53 enum values 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 (14)
|
||||
|
||||
| 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 |
|
||||
|
||||
### 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.)
|
||||
├── (50 hook directories — see tier tables above)
|
||||
├── zauc-mocks-bg, zauc-mocks-cache, … # 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 in the right order — auto-isolated by `script/run-ci-tests.ts` because they use `mock.module()`.
|
||||
- **`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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# src/hooks/anthropic-context-window-limit-recovery/ — Multi-Strategy Context Recovery
|
||||
|
||||
**Generated:** 2026-04-11
|
||||
**Generated:** 2026-05-08
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
/// <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> }
|
||||
tui: { showToast: (input: unknown) => Promise<unknown> }
|
||||
}
|
||||
|
||||
function createRecordingClient(): { client: FakeClient; calls: PromptAsyncCall[] } {
|
||||
const calls: PromptAsyncCall[] = []
|
||||
const client: FakeClient = {
|
||||
session: {
|
||||
promptAsync: async (input: PromptAsyncCall) => {
|
||||
calls.push(input)
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
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)
|
||||
})
|
||||
})
|
||||
+29
-2
@@ -5,7 +5,18 @@ 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"
|
||||
|
||||
export async function runAggressiveTruncationStrategy(params: {
|
||||
sessionID: string
|
||||
@@ -62,11 +73,27 @@ export async function runAggressiveTruncationStrategy(params: {
|
||||
clearSessionState(params.autoCompactState, params.sessionID)
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const inheritedTools = resolveInheritedPromptTools(params.sessionID)
|
||||
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)
|
||||
|
||||
await params.client.session.promptAsync({
|
||||
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 },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# src/hooks/atlas/ — Master Boulder Orchestrator
|
||||
|
||||
**Generated:** 2026-04-18
|
||||
**Generated:** 2026-05-08
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -21,7 +21,19 @@ 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,
|
||||
isCallerOrchestrator: options?.isCallerOrchestrator,
|
||||
}),
|
||||
"tool.execute.after": createToolExecuteAfterHandler({
|
||||
ctx,
|
||||
pendingFilePaths,
|
||||
pendingTaskRefs,
|
||||
autoCommit,
|
||||
getState,
|
||||
isCallerOrchestrator: options?.isCallerOrchestrator,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { appendSessionId, type BoulderState, upsertTaskSessionState } from "../../features/boulder-state"
|
||||
import { appendSessionId, type BoulderState, resolveBoulderPlanPath, upsertTaskSessionState } from "../../features/boulder-state"
|
||||
import { log } from "../../shared/logger"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
import { extractSessionIdFromOutput, validateSubagentSessionId } from "./subagent-session-id"
|
||||
@@ -40,7 +40,7 @@ export async function syncBackgroundLaunchSessionTracking(input: {
|
||||
|
||||
const { currentTask, shouldSkipTaskSessionUpdate } = resolveTaskContext(
|
||||
pendingTaskRef,
|
||||
boulderState.active_plan,
|
||||
resolveBoulderPlanPath(ctx.directory, boulderState),
|
||||
)
|
||||
|
||||
if (currentTask && !shouldSkipTaskSessionUpdate) {
|
||||
|
||||
@@ -91,6 +91,44 @@ describe("injectBoulderContinuation", () => {
|
||||
expect(sessionState.lastContinuationInjectedAt).toBe(123)
|
||||
})
|
||||
|
||||
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 = {
|
||||
directory: "/tmp",
|
||||
client: {
|
||||
session: {
|
||||
messages: messagesMock,
|
||||
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: {
|
||||
getTasksByParentSession: () => [{ status: "pending" }],
|
||||
} as unknown as Parameters<typeof injectBoulderContinuation>[0]["backgroundManager"],
|
||||
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)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import {
|
||||
isAgentRegistered,
|
||||
resolveRegisteredAgentName,
|
||||
@@ -9,10 +8,12 @@ import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../
|
||||
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"
|
||||
|
||||
const ACTIVE_BACKGROUND_TASK_STATUSES = new Set(["pending", "running"])
|
||||
|
||||
export async function injectBoulderContinuation(input: {
|
||||
ctx: PluginInput
|
||||
sessionID: string
|
||||
@@ -23,7 +24,7 @@ export async function injectBoulderContinuation(input: {
|
||||
worktreePath?: string
|
||||
preferredTaskSessionId?: string
|
||||
preferredTaskTitle?: string
|
||||
backgroundManager?: BackgroundManager
|
||||
backgroundManager?: BackgroundTaskStatusProvider
|
||||
sessionState: SessionState
|
||||
}): Promise<BoulderContinuationResult> {
|
||||
const {
|
||||
@@ -41,7 +42,7 @@ export async function injectBoulderContinuation(input: {
|
||||
} = 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) {
|
||||
|
||||
@@ -25,6 +25,16 @@ 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
|
||||
}
|
||||
|
||||
@@ -44,6 +54,7 @@ export function createAtlasEventHandler(input: {
|
||||
const state = sessions.get(sessionID)
|
||||
if (state) {
|
||||
state.lastEventWasAbortError = false
|
||||
state.skipNextIdleAfterRuntimeErrorRetry = false
|
||||
if (role === "user") {
|
||||
state.waitingForFinalWaveApproval = false
|
||||
}
|
||||
@@ -60,6 +71,7 @@ export function createAtlasEventHandler(input: {
|
||||
const state = sessions.get(sessionID)
|
||||
if (state) {
|
||||
state.lastEventWasAbortError = false
|
||||
state.skipNextIdleAfterRuntimeErrorRetry = false
|
||||
}
|
||||
}
|
||||
return
|
||||
@@ -71,6 +83,7 @@ export function createAtlasEventHandler(input: {
|
||||
const state = sessions.get(sessionID)
|
||||
if (state) {
|
||||
state.lastEventWasAbortError = false
|
||||
state.skipNextIdleAfterRuntimeErrorRetry = false
|
||||
}
|
||||
}
|
||||
return
|
||||
|
||||
@@ -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,28 +64,6 @@ 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 })
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,12 +4,14 @@ import {
|
||||
getTaskSessionState,
|
||||
readBoulderState,
|
||||
readCurrentTopLevelTask,
|
||||
resolveBoulderPlanPath,
|
||||
} from "../../features/boulder-state"
|
||||
import { getSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { getLastAgentFromSession } from "./session-last-agent"
|
||||
import { isSessionInBoulderLineage } from "./boulder-session-lineage"
|
||||
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
||||
import { log } from "../../shared/logger"
|
||||
import { settleAfterSessionIdle } from "../shared/session-idle-settle"
|
||||
import { injectBoulderContinuation } from "./boulder-continuation-injector"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
import { resolveActiveBoulderSession } from "./resolve-active-boulder-session"
|
||||
@@ -52,8 +54,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)
|
||||
@@ -163,7 +169,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({
|
||||
@@ -254,6 +260,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 +303,8 @@ export async function handleAtlasSessionIdle(input: {
|
||||
return
|
||||
}
|
||||
|
||||
await settleAfterSessionIdle(options?.idleSettleMs)
|
||||
|
||||
await injectContinuation({
|
||||
ctx,
|
||||
sessionID,
|
||||
|
||||
+336
-136
@@ -1,8 +1,9 @@
|
||||
import { describe, expect, test, beforeEach, afterEach, mock, afterAll } from "bun:test"
|
||||
import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import {
|
||||
writeBoulderState,
|
||||
clearBoulderState,
|
||||
@@ -10,35 +11,16 @@ import {
|
||||
} from "../../features/boulder-state"
|
||||
import type { BoulderState } from "../../features/boulder-state"
|
||||
import { _resetForTesting, registerAgentName, subagentSessions, updateSessionAgent } from "../../features/claude-code-session-state"
|
||||
import type { PendingTaskRef } from "./types"
|
||||
import type { AtlasHookOptions, PendingTaskRef } from "./types"
|
||||
import { createAtlasHook } from "./index"
|
||||
import { createToolExecuteAfterHandler } from "./tool-execute-after"
|
||||
import { createToolExecuteBeforeHandler } from "./tool-execute-before"
|
||||
|
||||
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-message-storage-${randomUUID()}`)
|
||||
const TEST_MESSAGE_STORAGE = join(TEST_STORAGE_ROOT, "message")
|
||||
const TEST_PART_STORAGE = join(TEST_STORAGE_ROOT, "part")
|
||||
|
||||
mock.module("../../features/hook-message-injector/constants", () => ({
|
||||
OPENCODE_STORAGE: TEST_STORAGE_ROOT,
|
||||
MESSAGE_STORAGE: TEST_MESSAGE_STORAGE,
|
||||
PART_STORAGE: TEST_PART_STORAGE,
|
||||
}))
|
||||
|
||||
mock.module("../../shared/opencode-message-dir", () => ({
|
||||
getMessageDir: (sessionID: string) => {
|
||||
const dir = join(TEST_MESSAGE_STORAGE, sessionID)
|
||||
return existsSync(dir) ? dir : null
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("../../shared/opencode-storage-detection", () => ({
|
||||
isSqliteBackend: () => false,
|
||||
}))
|
||||
|
||||
afterAll(() => { mock.restore() })
|
||||
|
||||
const { createAtlasHook } = await import("./index")
|
||||
const { createToolExecuteAfterHandler } = await import("./tool-execute-after")
|
||||
const { createToolExecuteBeforeHandler } = await import("./tool-execute-before")
|
||||
const { MESSAGE_STORAGE } = await import("../../features/hook-message-injector")
|
||||
const callerAgentBySession = new Map<string, string>()
|
||||
type MockAtlasInput = Parameters<typeof createAtlasHook>[0] & {
|
||||
_promptMock: ReturnType<typeof mock>
|
||||
_sessionGetMock: ReturnType<typeof mock>
|
||||
}
|
||||
|
||||
describe("atlas hook", () => {
|
||||
let TEST_DIR: string
|
||||
@@ -47,7 +29,7 @@ describe("atlas hook", () => {
|
||||
function createMockPluginInput(overrides?: {
|
||||
promptMock?: ReturnType<typeof mock>
|
||||
sessionGetMock?: ReturnType<typeof mock>
|
||||
}) {
|
||||
}): MockAtlasInput {
|
||||
const promptMock = overrides?.promptMock ?? mock(() => Promise.resolve())
|
||||
const sessionGetMock = overrides?.sessionGetMock ?? mock(async ({ path }: { path: { id: string } }) => ({
|
||||
data: {
|
||||
@@ -55,40 +37,42 @@ describe("atlas hook", () => {
|
||||
parentID: path.id.startsWith("ses_") ? "session-1" : "main-session-123",
|
||||
},
|
||||
}))
|
||||
const client = createOpencodeClient({ baseUrl: "http://localhost" })
|
||||
Reflect.set(client.session, "get", sessionGetMock)
|
||||
Reflect.set(client.session, "prompt", promptMock)
|
||||
Reflect.set(client.session, "promptAsync", promptMock)
|
||||
|
||||
return {
|
||||
directory: TEST_DIR,
|
||||
client: {
|
||||
session: {
|
||||
get: sessionGetMock,
|
||||
prompt: promptMock,
|
||||
promptAsync: promptMock,
|
||||
},
|
||||
},
|
||||
project: {} as Parameters<typeof createAtlasHook>[0]["project"],
|
||||
worktree: TEST_DIR,
|
||||
serverUrl: new URL("http://localhost"),
|
||||
$: {} as Parameters<typeof createAtlasHook>[0]["$"],
|
||||
client,
|
||||
_promptMock: promptMock,
|
||||
_sessionGetMock: sessionGetMock,
|
||||
} as unknown as Parameters<typeof createAtlasHook>[0] & {
|
||||
_promptMock: ReturnType<typeof mock>
|
||||
_sessionGetMock: ReturnType<typeof mock>
|
||||
}
|
||||
}
|
||||
|
||||
function setupMessageStorage(sessionID: string, agent: string): void {
|
||||
const messageDir = join(MESSAGE_STORAGE, sessionID)
|
||||
if (!existsSync(messageDir)) {
|
||||
mkdirSync(messageDir, { recursive: true })
|
||||
}
|
||||
const messageData = {
|
||||
agent,
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-7" },
|
||||
}
|
||||
writeFileSync(join(messageDir, "msg_test001.json"), JSON.stringify(messageData))
|
||||
callerAgentBySession.set(sessionID, agent)
|
||||
}
|
||||
|
||||
function cleanupMessageStorage(sessionID: string): void {
|
||||
const messageDir = join(MESSAGE_STORAGE, sessionID)
|
||||
if (existsSync(messageDir)) {
|
||||
rmSync(messageDir, { recursive: true, force: true })
|
||||
callerAgentBySession.delete(sessionID)
|
||||
}
|
||||
|
||||
function createTestAtlasHook(
|
||||
input = createMockPluginInput(),
|
||||
options: Partial<AtlasHookOptions> = {},
|
||||
): ReturnType<typeof createAtlasHook> {
|
||||
const resolvedOptions: AtlasHookOptions = {
|
||||
directory: TEST_DIR,
|
||||
idleSettleMs: 0,
|
||||
isCallerOrchestrator: async (sessionID) => callerAgentBySession.get(sessionID ?? "") === "atlas",
|
||||
...options,
|
||||
}
|
||||
return createAtlasHook(input, resolvedOptions)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -104,10 +88,12 @@ describe("atlas hook", () => {
|
||||
mkdirSync(SISYPHUS_DIR, { recursive: true })
|
||||
}
|
||||
clearBoulderState(TEST_DIR)
|
||||
callerAgentBySession.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
_resetForTesting()
|
||||
callerAgentBySession.clear()
|
||||
clearBoulderState(TEST_DIR)
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
@@ -117,12 +103,12 @@ describe("atlas hook", () => {
|
||||
describe("tool.execute.after handler", () => {
|
||||
test("should handle undefined output gracefully (issue #1035)", async () => {
|
||||
// given - hook and undefined output (e.g., from /review command)
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
const hook = createTestAtlasHook(createMockPluginInput())
|
||||
|
||||
// when - calling with undefined output
|
||||
const result = await hook["tool.execute.after"](
|
||||
{ tool: "task", sessionID: "session-123" },
|
||||
undefined as unknown as { title: string; output: string; metadata: Record<string, unknown> }
|
||||
undefined
|
||||
)
|
||||
|
||||
// then - returns undefined without throwing
|
||||
@@ -131,7 +117,7 @@ describe("atlas hook", () => {
|
||||
|
||||
test("should ignore non-task tools", async () => {
|
||||
// given - hook and non-task tool
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
const hook = createTestAtlasHook(createMockPluginInput())
|
||||
const output = {
|
||||
title: "Test Tool",
|
||||
output: "Original output",
|
||||
@@ -164,7 +150,7 @@ describe("atlas hook", () => {
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
const hook = createTestAtlasHook(createMockPluginInput())
|
||||
const output = {
|
||||
title: "Sisyphus Task",
|
||||
output: "Task completed successfully",
|
||||
@@ -188,7 +174,7 @@ describe("atlas hook", () => {
|
||||
const sessionID = "session-no-boulder-test"
|
||||
setupMessageStorage(sessionID, "atlas")
|
||||
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
const hook = createTestAtlasHook(createMockPluginInput())
|
||||
const output = {
|
||||
title: "Sisyphus Task",
|
||||
output: "Task completed successfully",
|
||||
@@ -225,7 +211,7 @@ describe("atlas hook", () => {
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
const hook = createTestAtlasHook(createMockPluginInput())
|
||||
const output = {
|
||||
title: "Sisyphus Task",
|
||||
output: "Task completed successfully",
|
||||
@@ -264,7 +250,7 @@ describe("atlas hook", () => {
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
const hook = createTestAtlasHook(createMockPluginInput())
|
||||
const output = {
|
||||
title: "Sisyphus Task",
|
||||
output: `Task completed
|
||||
@@ -301,7 +287,7 @@ session_id: ses_subagent_abc
|
||||
const sessionID = "session-standalone-metadata-test"
|
||||
setupMessageStorage(sessionID, "atlas")
|
||||
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
const hook = createTestAtlasHook(createMockPluginInput())
|
||||
const output = {
|
||||
title: "Sisyphus Task",
|
||||
output: `Task completed
|
||||
@@ -349,7 +335,7 @@ session_id: ses_standalone_def
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
const hook = createTestAtlasHook(createMockPluginInput())
|
||||
const output = {
|
||||
title: "Sisyphus Task",
|
||||
output: "Original output",
|
||||
@@ -386,7 +372,7 @@ session_id: ses_standalone_def
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
const hook = createTestAtlasHook(createMockPluginInput())
|
||||
const output = {
|
||||
title: "Sisyphus Task",
|
||||
output: "Task output",
|
||||
@@ -422,7 +408,7 @@ session_id: ses_standalone_def
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const hook = createAtlasHook(createMockPluginInput({
|
||||
const hook = createTestAtlasHook(createMockPluginInput({
|
||||
sessionGetMock: mock(async () => {
|
||||
throw new Error("session lookup failed")
|
||||
}),
|
||||
@@ -462,7 +448,7 @@ session_id: ses_standalone_def
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
const hook = createTestAtlasHook(createMockPluginInput())
|
||||
const output = {
|
||||
title: "Sisyphus Task",
|
||||
output: "Task output",
|
||||
@@ -499,7 +485,7 @@ session_id: ses_standalone_def
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
const hook = createTestAtlasHook(createMockPluginInput())
|
||||
const output = {
|
||||
title: "Sisyphus Task",
|
||||
output: "Task completed",
|
||||
@@ -536,7 +522,7 @@ session_id: ses_standalone_def
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
const hook = createTestAtlasHook(createMockPluginInput())
|
||||
const output = {
|
||||
title: "Sisyphus Task",
|
||||
output: "Task completed",
|
||||
@@ -581,6 +567,7 @@ session_id: ses_standalone_def
|
||||
ctx: createMockPluginInput(),
|
||||
pendingFilePaths,
|
||||
pendingTaskRefs,
|
||||
isCallerOrchestrator: async (id) => callerAgentBySession.get(id ?? "") === "atlas",
|
||||
})
|
||||
const afterHandler = createToolExecuteAfterHandler({
|
||||
ctx: createMockPluginInput(),
|
||||
@@ -588,6 +575,7 @@ session_id: ses_standalone_def
|
||||
pendingTaskRefs,
|
||||
autoCommit: true,
|
||||
getState: () => ({ promptFailureCount: 0 }),
|
||||
isCallerOrchestrator: async (id) => callerAgentBySession.get(id ?? "") === "atlas",
|
||||
})
|
||||
|
||||
// when - the task is captured before execution
|
||||
@@ -634,7 +622,7 @@ session_id: ses_standalone_def
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
const hook = createTestAtlasHook(createMockPluginInput())
|
||||
const output = {
|
||||
title: "Sisyphus Task",
|
||||
output: `Task completed successfully
|
||||
@@ -684,7 +672,7 @@ session_id: ses_auth_flow_123
|
||||
plan_name: "stable-task-key-plan",
|
||||
})
|
||||
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
const hook = createTestAtlasHook(createMockPluginInput())
|
||||
|
||||
// when - Atlas delegates task 1
|
||||
await hook["tool.execute.before"](
|
||||
@@ -744,7 +732,7 @@ session_id: ses_auth_flow_123
|
||||
plan_name: "cross-task-resume-plan",
|
||||
})
|
||||
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
const hook = createTestAtlasHook(createMockPluginInput())
|
||||
|
||||
// when - Atlas resumes an explicit prior session
|
||||
await hook["tool.execute.before"](
|
||||
@@ -806,7 +794,7 @@ session_id: ses_old_task_111
|
||||
},
|
||||
})
|
||||
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
const hook = createTestAtlasHook(createMockPluginInput())
|
||||
const output = {
|
||||
title: "Sisyphus Task",
|
||||
output: `Task continued successfully
|
||||
@@ -860,6 +848,7 @@ session_id: ses_old_task_111
|
||||
ctx: createMockPluginInput(),
|
||||
pendingFilePaths,
|
||||
pendingTaskRefs,
|
||||
isCallerOrchestrator: async (id) => callerAgentBySession.get(id ?? "") === "atlas",
|
||||
})
|
||||
const afterHandler = createToolExecuteAfterHandler({
|
||||
ctx: createMockPluginInput(),
|
||||
@@ -867,6 +856,7 @@ session_id: ses_old_task_111
|
||||
pendingTaskRefs,
|
||||
autoCommit: true,
|
||||
getState: () => ({ promptFailureCount: 0 }),
|
||||
isCallerOrchestrator: async (id) => callerAgentBySession.get(id ?? "") === "atlas",
|
||||
})
|
||||
|
||||
// when - two task() calls start before either one completes
|
||||
@@ -929,7 +919,7 @@ session_id: ses_parallel_collision_222
|
||||
plan_name: "untrusted-session-id-plan",
|
||||
})
|
||||
|
||||
const hook = createAtlasHook(createMockPluginInput({
|
||||
const hook = createTestAtlasHook(createMockPluginInput({
|
||||
sessionGetMock: mock(async ({ path }: { path: { id: string } }) => ({
|
||||
data: {
|
||||
id: path.id,
|
||||
@@ -987,7 +977,7 @@ session_id: ses_untrusted_999
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
const hook = createTestAtlasHook(createMockPluginInput())
|
||||
const output = {
|
||||
title: "Sisyphus Task",
|
||||
output: "Task completed successfully",
|
||||
@@ -1022,7 +1012,7 @@ session_id: ses_untrusted_999
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
const hook = createTestAtlasHook(createMockPluginInput())
|
||||
const output = {
|
||||
title: "Sisyphus Task",
|
||||
output: "Task completed successfully",
|
||||
@@ -1061,7 +1051,7 @@ session_id: ses_untrusted_999
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
const hook = createTestAtlasHook(createMockPluginInput())
|
||||
const output = {
|
||||
title: "Sisyphus Task",
|
||||
output: "Task completed successfully",
|
||||
@@ -1093,7 +1083,7 @@ session_id: ses_untrusted_999
|
||||
|
||||
test("should append delegation reminder when orchestrator writes outside .sisyphus/", async () => {
|
||||
// given
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
const hook = createTestAtlasHook(createMockPluginInput())
|
||||
const output = {
|
||||
title: "Write",
|
||||
output: "File written successfully",
|
||||
@@ -1107,14 +1097,14 @@ session_id: ses_untrusted_999
|
||||
)
|
||||
|
||||
// then
|
||||
expect(output.output).toContain("ORCHESTRATOR, not an IMPLEMENTER")
|
||||
expect(output.output).toContain("DELEGATION REQUIRED")
|
||||
expect(output.output).toContain("task")
|
||||
expect(output.output).toContain("task")
|
||||
})
|
||||
|
||||
test("should append delegation reminder when orchestrator edits outside .sisyphus/", async () => {
|
||||
// given
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
const hook = createTestAtlasHook(createMockPluginInput())
|
||||
const output = {
|
||||
title: "Edit",
|
||||
output: "File edited successfully",
|
||||
@@ -1128,12 +1118,12 @@ session_id: ses_untrusted_999
|
||||
)
|
||||
|
||||
// then
|
||||
expect(output.output).toContain("ORCHESTRATOR, not an IMPLEMENTER")
|
||||
expect(output.output).toContain("DELEGATION REQUIRED")
|
||||
})
|
||||
|
||||
test("should NOT append reminder when orchestrator writes inside .sisyphus/", async () => {
|
||||
// given
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
const hook = createTestAtlasHook(createMockPluginInput())
|
||||
const originalOutput = "File written successfully"
|
||||
const output = {
|
||||
title: "Write",
|
||||
@@ -1149,7 +1139,7 @@ session_id: ses_untrusted_999
|
||||
|
||||
// then
|
||||
expect(output.output).toBe(originalOutput)
|
||||
expect(output.output).not.toContain("ORCHESTRATOR, not an IMPLEMENTER")
|
||||
expect(output.output).not.toContain("DELEGATION REQUIRED")
|
||||
})
|
||||
|
||||
test("should NOT append reminder when non-orchestrator writes outside .sisyphus/", async () => {
|
||||
@@ -1157,7 +1147,7 @@ session_id: ses_untrusted_999
|
||||
const nonOrchestratorSession = "non-orchestrator-session"
|
||||
setupMessageStorage(nonOrchestratorSession, "sisyphus-junior")
|
||||
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
const hook = createTestAtlasHook(createMockPluginInput())
|
||||
const originalOutput = "File written successfully"
|
||||
const output = {
|
||||
title: "Write",
|
||||
@@ -1173,14 +1163,14 @@ session_id: ses_untrusted_999
|
||||
|
||||
// then
|
||||
expect(output.output).toBe(originalOutput)
|
||||
expect(output.output).not.toContain("ORCHESTRATOR, not an IMPLEMENTER")
|
||||
expect(output.output).not.toContain("DELEGATION REQUIRED")
|
||||
|
||||
cleanupMessageStorage(nonOrchestratorSession)
|
||||
})
|
||||
|
||||
test("should NOT append reminder for read-only tools", async () => {
|
||||
// given
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
const hook = createTestAtlasHook(createMockPluginInput())
|
||||
const originalOutput = "File content"
|
||||
const output = {
|
||||
title: "Read",
|
||||
@@ -1200,7 +1190,7 @@ session_id: ses_untrusted_999
|
||||
|
||||
test("should handle missing filePath gracefully", async () => {
|
||||
// given
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
const hook = createTestAtlasHook(createMockPluginInput())
|
||||
const originalOutput = "File written successfully"
|
||||
const output = {
|
||||
title: "Write",
|
||||
@@ -1221,7 +1211,7 @@ session_id: ses_untrusted_999
|
||||
describe("cross-platform path validation (Windows support)", () => {
|
||||
test("should NOT append reminder when orchestrator writes inside .sisyphus\\ (Windows backslash)", async () => {
|
||||
// given
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
const hook = createTestAtlasHook(createMockPluginInput())
|
||||
const originalOutput = "File written successfully"
|
||||
const output = {
|
||||
title: "Write",
|
||||
@@ -1237,12 +1227,12 @@ session_id: ses_untrusted_999
|
||||
|
||||
// then
|
||||
expect(output.output).toBe(originalOutput)
|
||||
expect(output.output).not.toContain("ORCHESTRATOR, not an IMPLEMENTER")
|
||||
expect(output.output).not.toContain("DELEGATION REQUIRED")
|
||||
})
|
||||
|
||||
test("should NOT append reminder when orchestrator writes inside .sisyphus with mixed separators", async () => {
|
||||
// given
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
const hook = createTestAtlasHook(createMockPluginInput())
|
||||
const originalOutput = "File written successfully"
|
||||
const output = {
|
||||
title: "Write",
|
||||
@@ -1258,12 +1248,12 @@ session_id: ses_untrusted_999
|
||||
|
||||
// then
|
||||
expect(output.output).toBe(originalOutput)
|
||||
expect(output.output).not.toContain("ORCHESTRATOR, not an IMPLEMENTER")
|
||||
expect(output.output).not.toContain("DELEGATION REQUIRED")
|
||||
})
|
||||
|
||||
test("should NOT append reminder for absolute Windows path inside .sisyphus\\", async () => {
|
||||
// given
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
const hook = createTestAtlasHook(createMockPluginInput())
|
||||
const originalOutput = "File written successfully"
|
||||
const output = {
|
||||
title: "Write",
|
||||
@@ -1279,12 +1269,12 @@ session_id: ses_untrusted_999
|
||||
|
||||
// then
|
||||
expect(output.output).toBe(originalOutput)
|
||||
expect(output.output).not.toContain("ORCHESTRATOR, not an IMPLEMENTER")
|
||||
expect(output.output).not.toContain("DELEGATION REQUIRED")
|
||||
})
|
||||
|
||||
test("should append reminder for Windows path outside .sisyphus\\", async () => {
|
||||
// given
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
const hook = createTestAtlasHook(createMockPluginInput())
|
||||
const output = {
|
||||
title: "Write",
|
||||
output: "File written successfully",
|
||||
@@ -1298,7 +1288,7 @@ session_id: ses_untrusted_999
|
||||
)
|
||||
|
||||
// then
|
||||
expect(output.output).toContain("ORCHESTRATOR, not an IMPLEMENTER")
|
||||
expect(output.output).toContain("DELEGATION REQUIRED")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1339,7 +1329,7 @@ session_id: ses_untrusted_999
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput)
|
||||
const hook = createTestAtlasHook(mockInput)
|
||||
|
||||
// when
|
||||
await hook.handler({
|
||||
@@ -1357,10 +1347,44 @@ session_id: ses_untrusted_999
|
||||
expect(callArgs.body.parts[0].text).toContain("2 remaining")
|
||||
})
|
||||
|
||||
test("should settle idle before injecting boulder continuation", async () => {
|
||||
// given
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [x] Task 2")
|
||||
|
||||
const state: BoulderState = {
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [MAIN_SESSION_ID],
|
||||
plan_name: "test-plan",
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createTestAtlasHook(mockInput, { idleSettleMs: 50 })
|
||||
|
||||
// when
|
||||
const startedAt = Date.now()
|
||||
const eventPromise = hook.handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: MAIN_SESSION_ID },
|
||||
},
|
||||
})
|
||||
await Promise.resolve()
|
||||
|
||||
// then
|
||||
expect(mockInput._promptMock).not.toHaveBeenCalled()
|
||||
|
||||
await eventPromise
|
||||
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(45)
|
||||
expect(mockInput._promptMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test("should not inject when no boulder state exists", async () => {
|
||||
// given - no boulder state
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput)
|
||||
const hook = createTestAtlasHook(mockInput)
|
||||
|
||||
// when
|
||||
await hook.handler({
|
||||
@@ -1388,7 +1412,7 @@ session_id: ses_untrusted_999
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput)
|
||||
const hook = createTestAtlasHook(mockInput)
|
||||
|
||||
// when - main session fires idle but is NOT in boulder's session_ids
|
||||
await hook.handler({
|
||||
@@ -1419,7 +1443,7 @@ session_id: ses_untrusted_999
|
||||
updateSessionAgent(subagentSessionID, "atlas")
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput)
|
||||
const hook = createTestAtlasHook(mockInput)
|
||||
|
||||
// when - subagent session goes idle before explicit tracking appends it
|
||||
await hook.handler({
|
||||
@@ -1451,7 +1475,7 @@ session_id: ses_untrusted_999
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput)
|
||||
const hook = createTestAtlasHook(mockInput)
|
||||
|
||||
await hook.handler({
|
||||
event: {
|
||||
@@ -1480,7 +1504,7 @@ session_id: ses_untrusted_999
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput)
|
||||
const hook = createTestAtlasHook(mockInput)
|
||||
|
||||
// when
|
||||
await hook.handler({
|
||||
@@ -1494,6 +1518,43 @@ session_id: ses_untrusted_999
|
||||
expect(mockInput._promptMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("should not inject when the mirrored worktree plan is complete even if the main repo plan is stale", async () => {
|
||||
// given
|
||||
const mainPlanPath = join(TEST_DIR, ".sisyphus", "plans", "worktree-complete-plan.md")
|
||||
const worktreeDir = join(tmpdir(), `atlas-worktree-${randomUUID()}`)
|
||||
const worktreePlanPath = join(worktreeDir, ".sisyphus", "plans", "worktree-complete-plan.md")
|
||||
mkdirSync(join(TEST_DIR, ".sisyphus", "plans"), { recursive: true })
|
||||
mkdirSync(join(worktreeDir, ".sisyphus", "plans"), { recursive: true })
|
||||
writeFileSync(mainPlanPath, "# Plan\n- [ ] Main repo task\n")
|
||||
writeFileSync(worktreePlanPath, "# Plan\n- [x] Worktree task\n")
|
||||
|
||||
writeBoulderState(TEST_DIR, {
|
||||
active_plan: mainPlanPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [MAIN_SESSION_ID],
|
||||
plan_name: "worktree-complete-plan",
|
||||
worktree_path: worktreeDir,
|
||||
})
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createTestAtlasHook(mockInput)
|
||||
|
||||
try {
|
||||
// when
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: MAIN_SESSION_ID },
|
||||
},
|
||||
})
|
||||
|
||||
// then
|
||||
expect(mockInput._promptMock).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
rmSync(worktreeDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("should skip when abort error occurred before idle", async () => {
|
||||
// given - boulder state with incomplete plan
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
@@ -1508,7 +1569,7 @@ session_id: ses_untrusted_999
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput)
|
||||
const hook = createTestAtlasHook(mockInput)
|
||||
|
||||
// when - send abort error then idle
|
||||
await hook.handler({
|
||||
@@ -1531,6 +1592,142 @@ session_id: ses_untrusted_999
|
||||
expect(mockInput._promptMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("#given boulder has incomplete tasks #when non-abort session error fires #then continuation injects immediately", async () => {
|
||||
// given - boulder state with incomplete plan
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
|
||||
|
||||
const state: BoulderState = {
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [MAIN_SESSION_ID],
|
||||
plan_name: "test-plan",
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createTestAtlasHook(mockInput)
|
||||
|
||||
// when - a recoverable runtime error fires without waiting for idle
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID: MAIN_SESSION_ID,
|
||||
error: { name: "RuntimeError", message: "provider overloaded" },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// then - boulder resumes work immediately
|
||||
expect(mockInput._promptMock).toHaveBeenCalledTimes(1)
|
||||
const callArgs = mockInput._promptMock.mock.calls[0][0]
|
||||
expect(callArgs.path.id).toBe(MAIN_SESSION_ID)
|
||||
expect(callArgs.body.parts[0].text).toContain("incomplete tasks")
|
||||
expect(callArgs.body.parts[0].text).toContain("2 remaining")
|
||||
})
|
||||
|
||||
test("#given boulder retried a runtime error #when stale idle follows #then no delayed duplicate retry is scheduled", async () => {
|
||||
// given - boulder state with incomplete plan
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
|
||||
|
||||
const state: BoulderState = {
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [MAIN_SESSION_ID],
|
||||
plan_name: "test-plan",
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const originalSetTimeout = globalThis.setTimeout
|
||||
const scheduledDelays: number[] = []
|
||||
globalThis.setTimeout = ((_handler: Parameters<typeof setTimeout>[0], timeout?: number, ..._args: unknown[]) => {
|
||||
scheduledDelays.push(timeout ?? 0)
|
||||
return originalSetTimeout(() => undefined, 0)
|
||||
}) as typeof setTimeout
|
||||
|
||||
try {
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createTestAtlasHook(mockInput)
|
||||
|
||||
// when - runtime error resumes immediately and OpenCode later emits stale idle
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID: MAIN_SESSION_ID,
|
||||
error: { name: "RuntimeError", message: "provider overloaded" },
|
||||
},
|
||||
},
|
||||
})
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: MAIN_SESSION_ID },
|
||||
},
|
||||
})
|
||||
|
||||
// then - stale idle is consumed, not converted into another scheduled continuation
|
||||
expect(mockInput._promptMock).toHaveBeenCalledTimes(1)
|
||||
expect(scheduledDelays).toHaveLength(0)
|
||||
} finally {
|
||||
globalThis.setTimeout = originalSetTimeout
|
||||
}
|
||||
})
|
||||
|
||||
test("#given boulder retried a runtime error #when assistant activity arrives #then next idle can continue", async () => {
|
||||
// given - boulder state with incomplete plan
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
|
||||
|
||||
const state: BoulderState = {
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [MAIN_SESSION_ID],
|
||||
plan_name: "test-plan",
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const originalDateNow = Date.now
|
||||
let now = 1000
|
||||
Date.now = () => now
|
||||
|
||||
try {
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createTestAtlasHook(mockInput)
|
||||
|
||||
// when - runtime error resumes immediately and then the retry run emits assistant activity
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID: MAIN_SESSION_ID,
|
||||
error: { name: "RuntimeError", message: "provider overloaded" },
|
||||
},
|
||||
},
|
||||
})
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: { info: { sessionID: MAIN_SESSION_ID, role: "assistant" } },
|
||||
},
|
||||
})
|
||||
now = 7000
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: MAIN_SESSION_ID },
|
||||
},
|
||||
})
|
||||
|
||||
// then - assistant activity marks the following idle as real work completion
|
||||
expect(mockInput._promptMock).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
Date.now = originalDateNow
|
||||
}
|
||||
})
|
||||
|
||||
test("should skip when background tasks are running", async () => {
|
||||
// given - boulder state with incomplete plan
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
@@ -1549,9 +1746,9 @@ session_id: ses_untrusted_999
|
||||
}
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput, {
|
||||
const hook = createTestAtlasHook(mockInput, {
|
||||
directory: TEST_DIR,
|
||||
backgroundManager: mockBackgroundManager as any,
|
||||
backgroundManager: mockBackgroundManager,
|
||||
})
|
||||
|
||||
// when
|
||||
@@ -1580,7 +1777,7 @@ session_id: ses_untrusted_999
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput, {
|
||||
const hook = createTestAtlasHook(mockInput, {
|
||||
directory: TEST_DIR,
|
||||
isContinuationStopped: (sessionID: string) => sessionID === MAIN_SESSION_ID,
|
||||
})
|
||||
@@ -1611,7 +1808,7 @@ session_id: ses_untrusted_999
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput)
|
||||
const hook = createTestAtlasHook(mockInput)
|
||||
|
||||
// when - abort error, then message update, then idle
|
||||
await hook.handler({
|
||||
@@ -1654,7 +1851,7 @@ session_id: ses_untrusted_999
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput)
|
||||
const hook = createTestAtlasHook(mockInput)
|
||||
|
||||
// when
|
||||
await hook.handler({
|
||||
@@ -1696,7 +1893,7 @@ session_id: ses_untrusted_999
|
||||
})
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput)
|
||||
const hook = createTestAtlasHook(mockInput)
|
||||
|
||||
// when
|
||||
await hook.handler({
|
||||
@@ -1731,7 +1928,7 @@ session_id: ses_untrusted_999
|
||||
setupMessageStorage(MAIN_SESSION_ID, "sisyphus")
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput)
|
||||
const hook = createTestAtlasHook(mockInput)
|
||||
|
||||
// when
|
||||
await hook.handler({
|
||||
@@ -1762,7 +1959,7 @@ session_id: ses_untrusted_999
|
||||
setupMessageStorage(MAIN_SESSION_ID, "hephaestus")
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput)
|
||||
const hook = createTestAtlasHook(mockInput)
|
||||
|
||||
await hook.handler({
|
||||
event: {
|
||||
@@ -1792,7 +1989,7 @@ session_id: ses_untrusted_999
|
||||
setupMessageStorage(MAIN_SESSION_ID, "sisyphus")
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput)
|
||||
const hook = createTestAtlasHook(mockInput)
|
||||
|
||||
// when
|
||||
await hook.handler({
|
||||
@@ -1824,7 +2021,7 @@ session_id: ses_untrusted_999
|
||||
registerAgentName("Atlas - Plan Executor")
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput)
|
||||
const hook = createTestAtlasHook(mockInput)
|
||||
|
||||
// when
|
||||
await hook.handler({
|
||||
@@ -1855,7 +2052,7 @@ session_id: ses_untrusted_999
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput)
|
||||
const hook = createTestAtlasHook(mockInput)
|
||||
|
||||
// when - fire multiple idle events in rapid succession (simulating infinite loop bug)
|
||||
await hook.handler({
|
||||
@@ -1896,7 +2093,7 @@ session_id: ses_untrusted_999
|
||||
|
||||
const promptMock = mock((): Promise<void> => Promise.reject(new Error("Bad Request")))
|
||||
const mockInput = createMockPluginInput({ promptMock })
|
||||
const hook = createAtlasHook(mockInput)
|
||||
const hook = createTestAtlasHook(mockInput)
|
||||
|
||||
const originalDateNow = Date.now
|
||||
let now = 0
|
||||
@@ -1938,7 +2135,7 @@ session_id: ses_untrusted_999
|
||||
promptMock.mockImplementationOnce(() => Promise.resolve())
|
||||
|
||||
const mockInput = createMockPluginInput({ promptMock })
|
||||
const hook = createAtlasHook(mockInput)
|
||||
const hook = createTestAtlasHook(mockInput)
|
||||
|
||||
const originalDateNow = Date.now
|
||||
let now = 0
|
||||
@@ -1974,7 +2171,7 @@ session_id: ses_untrusted_999
|
||||
|
||||
const promptMock = mock(() => Promise.reject(new Error("Bad Request")))
|
||||
const mockInput = createMockPluginInput({ promptMock })
|
||||
const hook = createAtlasHook(mockInput)
|
||||
const hook = createTestAtlasHook(mockInput)
|
||||
|
||||
const originalDateNow = Date.now
|
||||
let now = 0
|
||||
@@ -2015,7 +2212,7 @@ session_id: ses_untrusted_999
|
||||
|
||||
const promptMock = mock(() => Promise.reject(new Error("Bad Request")))
|
||||
const mockInput = createMockPluginInput({ promptMock })
|
||||
const hook = createAtlasHook(mockInput)
|
||||
const hook = createTestAtlasHook(mockInput)
|
||||
|
||||
const originalDateNow = Date.now
|
||||
let now = 0
|
||||
@@ -2060,7 +2257,7 @@ session_id: ses_untrusted_999
|
||||
}
|
||||
promptMock.mockImplementationOnce(() => Promise.resolve(undefined))
|
||||
const mockInput = createMockPluginInput({ promptMock })
|
||||
const hook = createAtlasHook(mockInput)
|
||||
const hook = createTestAtlasHook(mockInput)
|
||||
|
||||
const originalDateNow = Date.now
|
||||
let now = 0
|
||||
@@ -2111,7 +2308,7 @@ session_id: ses_untrusted_999
|
||||
|
||||
const promptMock = mock(() => Promise.reject(new Error("Bad Request")))
|
||||
const mockInput = createMockPluginInput({ promptMock })
|
||||
const hook = createAtlasHook(mockInput)
|
||||
const hook = createTestAtlasHook(mockInput)
|
||||
|
||||
const originalDateNow = Date.now
|
||||
let now = 0
|
||||
@@ -2155,7 +2352,7 @@ session_id: ses_untrusted_999
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput)
|
||||
const hook = createTestAtlasHook(mockInput)
|
||||
|
||||
// when - create abort state then delete
|
||||
await hook.handler({
|
||||
@@ -2208,7 +2405,7 @@ session_id: ses_untrusted_999
|
||||
updateSessionAgent(MAIN_SESSION_ID, "atlas")
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput)
|
||||
const hook = createTestAtlasHook(mockInput)
|
||||
|
||||
// when
|
||||
await hook.handler({
|
||||
@@ -2223,8 +2420,7 @@ session_id: ses_untrusted_999
|
||||
})
|
||||
|
||||
describe("delayed retry timer (abort-stuck fix)", () => {
|
||||
const capturedTimers = new Map<number, { callback: Function; cleared: boolean }>()
|
||||
let nextFakeId = 99000
|
||||
const capturedTimers = new Map<ReturnType<typeof setTimeout>, { callback: () => void | Promise<void>; cleared: boolean }>()
|
||||
const originalSetTimeout = globalThis.setTimeout
|
||||
const originalClearTimeout = globalThis.clearTimeout
|
||||
const originalDateNow = Date.now
|
||||
@@ -2232,28 +2428,32 @@ session_id: ses_untrusted_999
|
||||
|
||||
beforeEach(() => {
|
||||
capturedTimers.clear()
|
||||
nextFakeId = 99000
|
||||
fakeNow = 10000
|
||||
Date.now = () => fakeNow
|
||||
|
||||
globalThis.setTimeout = ((callback: Function, delay?: number, ...args: unknown[]) => {
|
||||
globalThis.setTimeout = ((callback: Parameters<typeof setTimeout>[0], delay?: number, ...args: unknown[]) => {
|
||||
const normalized = typeof delay === "number" ? delay : 0
|
||||
if (normalized >= 5000) {
|
||||
const id = nextFakeId++
|
||||
capturedTimers.set(id, { callback: () => callback(...args), cleared: false })
|
||||
return id as unknown as ReturnType<typeof setTimeout>
|
||||
const timerID = originalSetTimeout(() => undefined, 0)
|
||||
const capturedCallback = typeof callback === "function"
|
||||
? () => callback(...args)
|
||||
: () => undefined
|
||||
capturedTimers.set(timerID, { callback: capturedCallback, cleared: false })
|
||||
return timerID
|
||||
}
|
||||
return originalSetTimeout(callback as Parameters<typeof originalSetTimeout>[0], delay)
|
||||
}) as unknown as typeof setTimeout
|
||||
return typeof callback === "function"
|
||||
? originalSetTimeout(callback, delay, ...args)
|
||||
: originalSetTimeout(() => undefined, delay)
|
||||
}) as typeof setTimeout
|
||||
|
||||
globalThis.clearTimeout = ((id?: number | ReturnType<typeof setTimeout>) => {
|
||||
if (typeof id === "number" && capturedTimers.has(id)) {
|
||||
globalThis.clearTimeout = ((id?: ReturnType<typeof setTimeout>) => {
|
||||
if (id && capturedTimers.has(id)) {
|
||||
capturedTimers.get(id)!.cleared = true
|
||||
capturedTimers.delete(id)
|
||||
return
|
||||
}
|
||||
originalClearTimeout(id as Parameters<typeof originalClearTimeout>[0])
|
||||
}) as unknown as typeof clearTimeout
|
||||
originalClearTimeout(id)
|
||||
}) as typeof clearTimeout
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -2287,7 +2487,7 @@ session_id: ses_untrusted_999
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput)
|
||||
const hook = createTestAtlasHook(mockInput)
|
||||
|
||||
// when - first idle injects, second idle within cooldown schedules retry timer
|
||||
await hook.handler({
|
||||
@@ -2316,7 +2516,7 @@ session_id: ses_untrusted_999
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput)
|
||||
const hook = createTestAtlasHook(mockInput)
|
||||
|
||||
// when - first idle injects, then 3 rapid idles within cooldown
|
||||
await hook.handler({
|
||||
@@ -2351,7 +2551,7 @@ session_id: ses_untrusted_999
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput)
|
||||
const hook = createTestAtlasHook(mockInput)
|
||||
|
||||
// when - first idle injects, second schedules retry, then plan completes before timer fires
|
||||
await hook.handler({
|
||||
@@ -2382,7 +2582,7 @@ session_id: ses_untrusted_999
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput)
|
||||
const hook = createTestAtlasHook(mockInput)
|
||||
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } },
|
||||
@@ -2415,7 +2615,7 @@ session_id: ses_untrusted_999
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput)
|
||||
const hook = createTestAtlasHook(mockInput)
|
||||
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } },
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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,39 @@ 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, ".sisyphus", "plans", "worktree-plan.md")
|
||||
const worktreeDirectory = join(tmpdir(), `resolve-active-boulder-worktree-${randomUUID()}`)
|
||||
const worktreePlanPath = join(worktreeDirectory, ".sisyphus", "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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { getPlanProgress, readBoulderState } from "../../features/boulder-state"
|
||||
import { getPlanProgress, readBoulderState, resolveBoulderPlanPath } from "../../features/boulder-state"
|
||||
import type { BoulderState, PlanProgress } from "../../features/boulder-state"
|
||||
|
||||
export async function resolveActiveBoulderSession(input: {
|
||||
@@ -20,7 +20,7 @@ export async function resolveActiveBoulderSession(input: {
|
||||
return null
|
||||
}
|
||||
|
||||
const progress = getPlanProgress(boulderState.active_plan)
|
||||
const progress = getPlanProgress(resolveBoulderPlanPath(input.directory, boulderState))
|
||||
if (progress.isComplete) {
|
||||
return { boulderState, progress, appendedSession: false }
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
`
|
||||
@@ -168,47 +162,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:
|
||||
- \`.sisyphus/\` files (plans, notepads)
|
||||
- Reading any file (verification)
|
||||
- Running commands (verification)
|
||||
|
||||
Everything else: DELEGATE.
|
||||
|
||||
---
|
||||
`
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import {
|
||||
appendSessionId,
|
||||
getPlanProgress,
|
||||
getTaskSessionState,
|
||||
readBoulderState,
|
||||
resolveBoulderPlanPath,
|
||||
upsertTaskSessionState,
|
||||
} from "../../features/boulder-state"
|
||||
import { log } from "../../shared/logger"
|
||||
@@ -32,15 +32,17 @@ export function createToolExecuteAfterHandler(input: {
|
||||
pendingTaskRefs: Map<string, PendingTaskRef>
|
||||
autoCommit: boolean
|
||||
getState: (sessionID: string) => SessionState
|
||||
}): (toolInput: ToolExecuteAfterInput, toolOutput: ToolExecuteAfterOutput) => Promise<void> {
|
||||
isCallerOrchestrator?: (sessionID: string | undefined) => Promise<boolean>
|
||||
}): (toolInput: ToolExecuteAfterInput, toolOutput: ToolExecuteAfterOutput | undefined) => Promise<void> {
|
||||
const { ctx, pendingFilePaths, pendingTaskRefs, autoCommit, getState } = input
|
||||
const resolveIsCallerOrchestrator = input.isCallerOrchestrator ?? ((sessionID) => isCallerOrchestrator(sessionID, ctx.client))
|
||||
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
|
||||
}
|
||||
|
||||
@@ -98,12 +100,13 @@ export function createToolExecuteAfterHandler(input: {
|
||||
const extractedSessionId = metadataSessionId ?? extractSessionIdFromOutput(toolOutput.output)
|
||||
|
||||
if (boulderState) {
|
||||
const progress = getPlanProgress(boulderState.active_plan)
|
||||
const planPath = resolveBoulderPlanPath(ctx.directory, 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
|
||||
@@ -136,7 +139,7 @@ export function createToolExecuteAfterHandler(input: {
|
||||
const originalResponse = toolOutput.output
|
||||
const shouldPauseForApproval = sessionState
|
||||
? shouldPauseForFinalWaveApproval({
|
||||
planPath: boulderState.active_plan,
|
||||
planPath,
|
||||
taskOutput: originalResponse,
|
||||
sessionState,
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { readBoulderState, readCurrentTopLevelTask, resolveBoulderPlanPath } 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"
|
||||
@@ -13,18 +13,20 @@ export function createToolExecuteBeforeHandler(input: {
|
||||
ctx: PluginInput
|
||||
pendingFilePaths: Map<string, string>
|
||||
pendingTaskRefs: Map<string, PendingTaskRef>
|
||||
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 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
|
||||
}
|
||||
|
||||
@@ -60,7 +62,7 @@ export function createToolExecuteBeforeHandler(input: {
|
||||
} else {
|
||||
const boulderState = readBoulderState(ctx.directory)
|
||||
const currentTask = boulderState
|
||||
? readCurrentTopLevelTask(boulderState.active_plan)
|
||||
? readCurrentTopLevelTask(resolveBoulderPlanPath(ctx.directory, boulderState))
|
||||
: null
|
||||
if (currentTask) {
|
||||
const task = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# src/hooks/claude-code-hooks/ — Claude Code Compatibility
|
||||
|
||||
**Generated:** 2026-04-18
|
||||
**Generated:** 2026-05-08
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# src/hooks/comment-checker/ — AI Slop Comment Blocker
|
||||
|
||||
**Generated:** 2026-04-18
|
||||
**Generated:** 2026-05-08
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -35,7 +35,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
|
||||
}
|
||||
@@ -160,5 +168,5 @@ export function createCompactionContextInjector(options?: {
|
||||
}
|
||||
}
|
||||
|
||||
return { capture, inject, event }
|
||||
return { capture, restore, inject, event }
|
||||
}
|
||||
|
||||
@@ -19,7 +19,9 @@ 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"
|
||||
|
||||
function createMockContext(
|
||||
messageResponses: Array<Array<{ info?: Record<string, unknown> }>>,
|
||||
@@ -42,6 +44,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 +118,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 +134,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
|
||||
@@ -164,12 +170,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 },
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -203,6 +219,99 @@ describe("createCompactionContextInjector", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("re-injects checkpointed agent config during autocontinue before synthetic continue", async () => {
|
||||
//#given
|
||||
const promptAsyncMock = mock(async () => ({}))
|
||||
const ctx = createMockContext(
|
||||
[
|
||||
[
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
tools: { bash: "allow" },
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
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)
|
||||
expect(promptAsyncMock).toHaveBeenCalledWith({
|
||||
path: { id: "ses_autocontinue_checkpoint" },
|
||||
body: {
|
||||
noReply: true,
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
tools: { bash: true },
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: expect.stringContaining("restore checkpointed session agent configuration"),
|
||||
},
|
||||
],
|
||||
},
|
||||
query: { 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 () => ({}))
|
||||
|
||||
@@ -28,7 +28,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 +73,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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -2,15 +2,27 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
||||
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 +35,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"
|
||||
@@ -46,23 +103,35 @@ function resolveSessionID(props?: Record<string, unknown>): string | undefined {
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,6 +26,10 @@ 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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,8 @@ export { createNonInteractiveEnvHook } from "./non-interactive-env";
|
||||
export { createInteractiveBashSessionHook } from "./interactive-bash-session";
|
||||
|
||||
export { createThinkingBlockValidatorHook } from "./thinking-block-validator";
|
||||
export { createTeamMailboxInjector } from "./team-mailbox-injector";
|
||||
export { createTeamModeStatusInjector } from "./team-mode-status-injector";
|
||||
export { createToolPairValidatorHook } from "./tool-pair-validator";
|
||||
export { createCategorySkillReminderHook } from "./category-skill-reminder";
|
||||
export { createRalphLoopHook, type RalphLoopHook } from "./ralph-loop";
|
||||
@@ -45,6 +47,7 @@ export { createSisyphusJuniorNotepadHook } from "./sisyphus-junior-notepad";
|
||||
export { createTaskResumeInfoHook } from "./task-resume-info";
|
||||
export { createStartWorkHook } from "./start-work";
|
||||
export { createAtlasHook } from "./atlas";
|
||||
export { createTeamToolGating } from "./team-tool-gating"
|
||||
export { createDelegateTaskRetryHook } from "./delegate-task-retry";
|
||||
export { createQuestionLabelTruncatorHook } from "./question-label-truncator";
|
||||
export { createStopContinuationGuardHook, type StopContinuationGuard } from "./stop-continuation-guard";
|
||||
@@ -62,3 +65,4 @@ export { createReadImageResizerHook } from "./read-image-resizer"
|
||||
export { createTodoDescriptionOverrideHook } from "./todo-description-override"
|
||||
export { createWebFetchRedirectGuardHook } from "./webfetch-redirect-guard"
|
||||
export { createLegacyPluginToastHook } from "./legacy-plugin-toast"
|
||||
export { createFsyncSkipWarningHook } from "./fsync-skip-warning"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# src/hooks/keyword-detector/ — Mode Keyword Injection
|
||||
|
||||
**Generated:** 2026-04-11
|
||||
**Generated:** 2026-05-08
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
8 files + 3 mode subdirs (~1665 LOC). Transform Tier hook on `messages.transform`. Scans first user message for mode keywords (ultrawork, search, analyze) and injects mode-specific system prompts.
|
||||
Transform Tier hook on `messages.transform`. Scans first user message for mode keywords (ultrawork, search, analyze, team) and injects mode-specific system prompts.
|
||||
|
||||
## KEYWORDS
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
| `ultrawork` / `ulw` | `/\b(ultrawork|ulw)\b/i` | Full orchestration mode — parallel agents, deep exploration, relentless execution |
|
||||
| Search mode | `SEARCH_PATTERN` (from `search/`) | Web/doc search focus prompt injection |
|
||||
| Analyze mode | `ANALYZE_PATTERN` (from `analyze/`) | Deep analysis mode prompt injection |
|
||||
| Team mode | `TEAM_PATTERN` (from `team/`) | Forces orchestration via `team_*` tools when user invokes `team mode` / `팀 모드` / `팀으로`; instructs user to enable `team_mode.enabled` if tools are absent |
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
@@ -31,10 +32,12 @@ keyword-detector/
|
||||
│ ├── index.ts
|
||||
│ ├── pattern.ts # SEARCH_PATTERN regex
|
||||
│ └── message.ts # SEARCH_MESSAGE
|
||||
└── analyze/
|
||||
├── analyze/
|
||||
│ ├── index.ts
|
||||
│ └── default.ts # ANALYZE_PATTERN + ANALYZE_MESSAGE
|
||||
└── team/
|
||||
├── index.ts
|
||||
├── pattern.ts # ANALYZE_PATTERN regex
|
||||
└── message.ts # ANALYZE_MESSAGE
|
||||
└── default.ts # TEAM_PATTERN + TEAM_MESSAGE
|
||||
```
|
||||
|
||||
## DETECTION LOGIC
|
||||
@@ -44,11 +47,24 @@ chat.message (user input)
|
||||
→ extractPromptText(parts)
|
||||
→ isSystemDirective? → skip
|
||||
→ removeSystemReminders(text) # strip <SYSTEM_REMINDER> blocks
|
||||
→ detectKeywordsWithType(cleanText, agentName, modelID)
|
||||
→ detectKeywordsWithType(cleanText, agentName, modelID, disabledKeywords)
|
||||
→ isPlannerAgent(agentName)? → filter out ultrawork
|
||||
→ for each detected keyword: inject mode message into output
|
||||
```
|
||||
|
||||
## CONFIG
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"keyword_detector": {
|
||||
// Skip injection for any keyword in this list. Allowed: "ultrawork", "search", "analyze", "team".
|
||||
"disabled_keywords": ["search", "analyze"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Default: empty/missing → all four detectors active. Schema lives at [src/config/schema/keyword-detector.ts](../../config/schema/keyword-detector.ts).
|
||||
|
||||
## GUARDS
|
||||
|
||||
- **System directive skip**: Messages tagged as system directives are not scanned (prevents infinite loops)
|
||||
|
||||
@@ -4,25 +4,48 @@ export const INLINE_CODE_PATTERN = /`[^`]+`/g
|
||||
export { isPlannerAgent, isNonOmoAgent, getUltraworkMessage } from "./ultrawork"
|
||||
export { SEARCH_PATTERN, SEARCH_MESSAGE } from "./search"
|
||||
export { ANALYZE_PATTERN, ANALYZE_MESSAGE } from "./analyze"
|
||||
export { TEAM_PATTERN, TEAM_MESSAGE } from "./team"
|
||||
export { HYPERPLAN_PATTERN, HYPERPLAN_MESSAGE } from "./hyperplan"
|
||||
|
||||
import type { KeywordType } from "../../config/schema/keyword-detector"
|
||||
import { getUltraworkMessage } from "./ultrawork"
|
||||
import { SEARCH_PATTERN, SEARCH_MESSAGE } from "./search"
|
||||
import { TEAM_PATTERN, TEAM_MESSAGE } from "./team"
|
||||
import { HYPERPLAN_PATTERN, HYPERPLAN_MESSAGE } from "./hyperplan"
|
||||
|
||||
// Hyperplan-ultrawork combo: strict adjacency, both word orders
|
||||
export const HYPERPLAN_ULTRAWORK_PATTERN =
|
||||
/\b(?:hpp|hyperplan)\s+(?:ulw|ultrawork)\b|\b(?:ulw|ultrawork)\s+(?:hpp|hyperplan)\b/i
|
||||
|
||||
const HYPERPLAN_ULTRAWORK_BANNER = `<hyperplan-ultrawork-mode>
|
||||
**MANDATORY**: Say "HYPERPLAN ULTRAWORK MODE ENABLED!" exactly once as your first response. Do NOT say the standalone "ULTRAWORK MODE ENABLED!" or "HYPERPLAN MODE ENABLED!" banners.
|
||||
|
||||
Apply the ultrawork protocol below as your execution framework. You MUST ALSO load the hyperplan skill immediately via \`skill(name="hyperplan")\` and follow its full adversarial workflow — do NOT improvise, do NOT skip rounds, do NOT write the plan yourself.
|
||||
</hyperplan-ultrawork-mode>`
|
||||
|
||||
export function getHyperplanUltraworkMessage(agentName?: string, modelID?: string): string {
|
||||
return `${HYPERPLAN_ULTRAWORK_BANNER}\n\n${getUltraworkMessage(agentName, modelID)}`
|
||||
}
|
||||
|
||||
export type KeywordDetector = {
|
||||
type: KeywordType
|
||||
pattern: RegExp
|
||||
message: string | ((agentName?: string, modelID?: string) => string)
|
||||
}
|
||||
|
||||
export const KEYWORD_DETECTORS: KeywordDetector[] = [
|
||||
{
|
||||
type: "ultrawork",
|
||||
pattern: /\b(ultrawork|ulw)\b/i,
|
||||
message: getUltraworkMessage,
|
||||
},
|
||||
{
|
||||
type: "search",
|
||||
pattern: SEARCH_PATTERN,
|
||||
message: SEARCH_MESSAGE,
|
||||
},
|
||||
{
|
||||
type: "analyze",
|
||||
pattern:
|
||||
/\b(analyze|analyse|investigate|examine|research|study|deep[\s-]?dive|inspect|audit|evaluate|assess|review|diagnose|scrutinize|dissect|debug|comprehend|interpret|breakdown|understand)\b|why\s+is|how\s+does|how\s+to|분석|조사|파악|연구|검토|진단|이해|설명|원인|이유|뜯어봐|따져봐|평가|해석|디버깅|디버그|어떻게|왜|살펴|分析|調査|解析|検討|研究|診断|理解|説明|検証|精査|究明|デバッグ|なぜ|どう|仕組み|调查|检查|剖析|深入|诊断|解释|调试|为什么|原理|搞清楚|弄明白|phân tích|điều tra|nghiên cứu|kiểm tra|xem xét|chẩn đoán|giải thích|tìm hiểu|gỡ lỗi|tại sao/i,
|
||||
message: `[analyze-mode]
|
||||
@@ -41,4 +64,19 @@ SYNTHESIZE findings before proceeding.
|
||||
MANDATORY delegate_task params: ALWAYS include load_skills and run_in_background when calling delegate_task. Evaluate available skills before dispatch - pass task-appropriate skills when relevant, pass [] ONLY when no skill matches the task domain.
|
||||
Example: delegate_task(subagent_type="explore", prompt="...", run_in_background=true, load_skills=[])`,
|
||||
},
|
||||
{
|
||||
type: "team",
|
||||
pattern: TEAM_PATTERN,
|
||||
message: TEAM_MESSAGE,
|
||||
},
|
||||
{
|
||||
type: "hyperplan",
|
||||
pattern: HYPERPLAN_PATTERN,
|
||||
message: HYPERPLAN_MESSAGE,
|
||||
},
|
||||
{
|
||||
type: "hyperplan-ultrawork",
|
||||
pattern: HYPERPLAN_ULTRAWORK_PATTERN,
|
||||
message: getHyperplanUltraworkMessage,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { KeywordType } from "../../config/schema/keyword-detector"
|
||||
import {
|
||||
KEYWORD_DETECTORS,
|
||||
CODE_BLOCK_PATTERN,
|
||||
@@ -5,7 +6,7 @@ import {
|
||||
} from "./constants"
|
||||
|
||||
export interface DetectedKeyword {
|
||||
type: "ultrawork" | "search" | "analyze"
|
||||
type: KeywordType
|
||||
message: string
|
||||
}
|
||||
|
||||
@@ -13,9 +14,12 @@ export function removeCodeBlocks(text: string): string {
|
||||
return text.replace(CODE_BLOCK_PATTERN, "").replace(INLINE_CODE_PATTERN, "")
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves message to string, handling both static strings and dynamic functions.
|
||||
*/
|
||||
const SLASH_COMMAND_LEAD_PATTERN = /^\s*\/[a-zA-Z][\w-]*(?:\s|$)/
|
||||
|
||||
export function looksLikeSlashCommand(text: string): boolean {
|
||||
return SLASH_COMMAND_LEAD_PATTERN.test(text)
|
||||
}
|
||||
|
||||
function resolveMessage(
|
||||
message: string | ((agentName?: string, modelID?: string) => string),
|
||||
agentName?: string,
|
||||
@@ -24,22 +28,35 @@ function resolveMessage(
|
||||
return typeof message === "function" ? message(agentName, modelID) : message
|
||||
}
|
||||
|
||||
export function detectKeywords(text: string, agentName?: string, modelID?: string): string[] {
|
||||
const textWithoutCode = removeCodeBlocks(text)
|
||||
return KEYWORD_DETECTORS.filter(({ pattern }) =>
|
||||
pattern.test(textWithoutCode)
|
||||
).map(({ message }) => resolveMessage(message, agentName, modelID))
|
||||
export function detectKeywords(
|
||||
text: string,
|
||||
agentName?: string,
|
||||
modelID?: string,
|
||||
disabledKeywords?: ReadonlyArray<KeywordType>,
|
||||
): string[] {
|
||||
return detectKeywordsWithType(text, agentName, modelID, disabledKeywords).map(
|
||||
({ message }) => message,
|
||||
)
|
||||
}
|
||||
|
||||
export function detectKeywordsWithType(text: string, agentName?: string, modelID?: string): DetectedKeyword[] {
|
||||
export function detectKeywordsWithType(
|
||||
text: string,
|
||||
agentName?: string,
|
||||
modelID?: string,
|
||||
disabledKeywords?: ReadonlyArray<KeywordType>,
|
||||
): DetectedKeyword[] {
|
||||
const textWithoutCode = removeCodeBlocks(text)
|
||||
const types: Array<"ultrawork" | "search" | "analyze"> = ["ultrawork", "search", "analyze"]
|
||||
return KEYWORD_DETECTORS.map(({ pattern, message }, index) => ({
|
||||
const disabled = new Set<KeywordType>(disabledKeywords ?? [])
|
||||
// Intersection rule: combo requires BOTH base keywords enabled
|
||||
if (disabled.has("ultrawork") || disabled.has("hyperplan")) {
|
||||
disabled.add("hyperplan-ultrawork")
|
||||
}
|
||||
return KEYWORD_DETECTORS.map(({ type, pattern, message }) => ({
|
||||
matches: pattern.test(textWithoutCode),
|
||||
type: types[index],
|
||||
type,
|
||||
message: resolveMessage(message, agentName, modelID),
|
||||
}))
|
||||
.filter((result) => result.matches)
|
||||
.filter((result) => result.matches && !disabled.has(result.type))
|
||||
.map(({ type, message }) => ({ type, message }))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { detectKeywordsWithType, extractPromptText } from "./detector"
|
||||
import type { KeywordDetectorConfig } from "../../config/schema/keyword-detector"
|
||||
import type { DetectedKeyword } from "./detector"
|
||||
import { detectKeywordsWithType, extractPromptText, looksLikeSlashCommand } from "./detector"
|
||||
import { isPlannerAgent, isNonOmoAgent } from "./constants"
|
||||
import { log } from "../../shared"
|
||||
import {
|
||||
@@ -14,11 +16,19 @@ import {
|
||||
import type { ContextCollector } from "../../features/context-injector"
|
||||
import type { RalphLoopHook } from "../ralph-loop"
|
||||
|
||||
function suppressComboStandalones(detected: DetectedKeyword[]): DetectedKeyword[] {
|
||||
const hasCombo = detected.some((k) => k.type === "hyperplan-ultrawork")
|
||||
if (!hasCombo) return detected
|
||||
return detected.filter((k) => k.type !== "ultrawork" && k.type !== "hyperplan")
|
||||
}
|
||||
|
||||
export function createKeywordDetectorHook(
|
||||
ctx: PluginInput,
|
||||
_collector?: ContextCollector,
|
||||
_ralphLoop?: Pick<RalphLoopHook, "startLoop">
|
||||
_ralphLoop?: Pick<RalphLoopHook, "startLoop">,
|
||||
config?: KeywordDetectorConfig,
|
||||
) {
|
||||
const disabledKeywords = config?.disabled_keywords
|
||||
function getRuntimeVariant(input: { variant?: string }, message: Record<string, unknown>): string | undefined {
|
||||
if (typeof message["variant"] === "string") {
|
||||
return message["variant"]
|
||||
@@ -48,6 +58,11 @@ export function createKeywordDetectorHook(
|
||||
return
|
||||
}
|
||||
|
||||
if (looksLikeSlashCommand(promptText)) {
|
||||
log(`[keyword-detector] Skipping slash command invocation`, { sessionID: input.sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const currentAgent = getSessionAgent(input.sessionID) ?? input.agent
|
||||
|
||||
// Skip all keyword injection for non-OMO agents (e.g., OpenCode-Builder, Plan)
|
||||
@@ -59,13 +74,16 @@ export function createKeywordDetectorHook(
|
||||
// Remove system-reminder content to prevent automated system messages from triggering mode keywords
|
||||
const cleanText = removeSystemReminders(promptText)
|
||||
const modelID = input.model?.modelID
|
||||
let detectedKeywords = detectKeywordsWithType(cleanText, currentAgent, modelID)
|
||||
let detectedKeywords = detectKeywordsWithType(cleanText, currentAgent, modelID, disabledKeywords)
|
||||
detectedKeywords = suppressComboStandalones(detectedKeywords)
|
||||
|
||||
if (isPlannerAgent(currentAgent)) {
|
||||
const preFilterCount = detectedKeywords.length
|
||||
detectedKeywords = detectedKeywords.filter((k) => k.type !== "ultrawork")
|
||||
detectedKeywords = detectedKeywords.filter(
|
||||
(k) => k.type !== "ultrawork" && k.type !== "hyperplan" && k.type !== "hyperplan-ultrawork"
|
||||
)
|
||||
if (preFilterCount > detectedKeywords.length) {
|
||||
log(`[keyword-detector] Filtered ultrawork keywords for planner agent`, { sessionID: input.sessionID, agent: currentAgent })
|
||||
log(`[keyword-detector] Filtered ultrawork/hyperplan keywords for planner agent`, { sessionID: input.sessionID, agent: currentAgent })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +101,9 @@ export function createKeywordDetectorHook(
|
||||
const isNonMainSession = mainSessionID && input.sessionID !== mainSessionID
|
||||
|
||||
if (isNonMainSession) {
|
||||
detectedKeywords = detectedKeywords.filter((k) => k.type === "ultrawork")
|
||||
detectedKeywords = detectedKeywords.filter(
|
||||
(k) => k.type === "ultrawork" || k.type === "hyperplan-ultrawork"
|
||||
)
|
||||
if (detectedKeywords.length === 0) {
|
||||
log(`[keyword-detector] Skipping non-ultrawork keywords in non-main session`, {
|
||||
sessionID: input.sessionID,
|
||||
@@ -123,6 +143,44 @@ export function createKeywordDetectorHook(
|
||||
|
||||
}
|
||||
|
||||
const hasHyperplan = detectedKeywords.some((k) => k.type === "hyperplan")
|
||||
if (hasHyperplan) {
|
||||
log(`[keyword-detector] Hyperplan mode activated`, {
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
|
||||
ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Hyperplan Mode Activated",
|
||||
message: "Adversarial planning engaged. 5 hostile members will cross-critique.",
|
||||
variant: "success" as const,
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch((err) =>
|
||||
log(`[keyword-detector] Failed to show toast`, {
|
||||
error: err,
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const hasHyperplanUltrawork = detectedKeywords.some((k) => k.type === "hyperplan-ultrawork")
|
||||
if (hasHyperplanUltrawork) {
|
||||
log(`[keyword-detector] Hyperplan Ultrawork mode activated`, { sessionID: input.sessionID })
|
||||
ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Hyperplan Ultrawork Mode Activated",
|
||||
message: "Ultrawork execution with adversarial hyperplan workflow.",
|
||||
variant: "success" as const,
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch((err) => log(`[keyword-detector] Failed to show toast`, { error: err, sessionID: input.sessionID }))
|
||||
}
|
||||
|
||||
const textPartIndex = output.parts.findIndex((p) => p.type === "text" && p.text !== undefined)
|
||||
if (textPartIndex === -1) {
|
||||
log(`[keyword-detector] No text part found, skipping injection`, { sessionID: input.sessionID })
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { createKeywordDetectorHook } from "./index"
|
||||
import { setMainSession, _resetForTesting } from "../../features/claude-code-session-state"
|
||||
import * as sharedModule from "../../shared"
|
||||
import * as sessionState from "../../features/claude-code-session-state"
|
||||
|
||||
describe("keyword-detector hyperplan-ultrawork combo", () => {
|
||||
let logSpy: ReturnType<typeof spyOn>
|
||||
let getMainSessionSpy: ReturnType<typeof spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
_resetForTesting()
|
||||
logSpy = spyOn(sharedModule, "log").mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
logSpy?.mockRestore()
|
||||
getMainSessionSpy?.mockRestore()
|
||||
_resetForTesting()
|
||||
})
|
||||
|
||||
function createMockPluginInput(options: { toastCalls?: string[] } = {}) {
|
||||
const toastCalls = options.toastCalls ?? []
|
||||
return {
|
||||
client: {
|
||||
tui: {
|
||||
showToast: async (opts: { body: { title: string } }) => {
|
||||
toastCalls.push(opts.body.title)
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as PluginInput
|
||||
}
|
||||
|
||||
test("should inject combo message when user types 'hpp ulw' (forward order)", async () => {
|
||||
// given - main session with adjacent forward-order combo keywords
|
||||
const sessionID = "combo-forward-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput())
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "hpp ulw refactor the auth module" }],
|
||||
}
|
||||
|
||||
// when - keyword detection runs
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - combo banner and embedded ultrawork content both present
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toContain("<hyperplan-ultrawork-mode>")
|
||||
expect(textPart!.text).toContain("<ultrawork-mode>")
|
||||
expect(textPart!.text).toContain("refactor the auth module")
|
||||
})
|
||||
|
||||
test("should inject combo message when user types 'ulw hpp' (reverse order)", async () => {
|
||||
// given - main session with adjacent reverse-order combo keywords
|
||||
const sessionID = "combo-reverse-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput())
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "ulw hpp ship this feature" }],
|
||||
}
|
||||
|
||||
// when - keyword detection runs
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - combo fires identically regardless of word order
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toContain("<hyperplan-ultrawork-mode>")
|
||||
expect(textPart!.text).toContain("<ultrawork-mode>")
|
||||
expect(textPart!.text).toContain("ship this feature")
|
||||
})
|
||||
|
||||
test("should NOT trigger combo on non-adjacent 'hpp do ulw' but fire both standalones instead", async () => {
|
||||
// given - keywords separated by another word block adjacency requirement
|
||||
const sessionID = "combo-non-adjacent-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput())
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "hpp do ulw stuff" }],
|
||||
}
|
||||
|
||||
// when - keyword detection runs
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - combo absent, both standalone banners injected separately
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).not.toContain("<hyperplan-ultrawork-mode>")
|
||||
expect(textPart!.text).toContain("<hyperplan-mode>")
|
||||
expect(textPart!.text).toContain("<ultrawork-mode>")
|
||||
})
|
||||
|
||||
test("should suppress standalone messages when combo fires (only ONE banner injected)", async () => {
|
||||
// given - combo keywords that would also match standalone patterns
|
||||
const sessionID = "combo-suppress-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput())
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "hpp ulw build" }],
|
||||
}
|
||||
|
||||
// when - keyword detection runs
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - only combo banner present, standalone hyperplan suppressed, ultrawork content appears once via embed
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toContain("<hyperplan-ultrawork-mode>")
|
||||
expect(textPart!.text).not.toContain("<hyperplan-mode>")
|
||||
const ultraworkMatches = textPart!.text!.match(/<ultrawork-mode>/g) ?? []
|
||||
expect(ultraworkMatches).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("should fire combo toast and suppress standalone toasts", async () => {
|
||||
// given - combo keywords with toast tracking
|
||||
const sessionID = "combo-toast-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const toastCalls: string[] = []
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput({ toastCalls }))
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "hpp ulw do it" }],
|
||||
}
|
||||
|
||||
// when - combo fires
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - only combo toast title is shown, standalone toasts suppressed
|
||||
expect(toastCalls).toContain("Hyperplan Ultrawork Mode Activated")
|
||||
expect(toastCalls).not.toContain("Ultrawork Mode Activated")
|
||||
expect(toastCalls).not.toContain("Hyperplan Mode Activated")
|
||||
})
|
||||
|
||||
test("should disable combo only when disabled_keywords includes 'hyperplan-ultrawork' (standalones still fire)", async () => {
|
||||
// given - combo keyword disabled but standalones remain enabled
|
||||
const sessionID = "combo-disabled-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(
|
||||
createMockPluginInput(),
|
||||
undefined,
|
||||
undefined,
|
||||
{ disabled_keywords: ["hyperplan-ultrawork"] },
|
||||
)
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "hpp ulw work it" }],
|
||||
}
|
||||
|
||||
// when - keyword detection runs with combo disabled
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - combo absent, both individual standalones still match and inject
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).not.toContain("<hyperplan-ultrawork-mode>")
|
||||
expect(textPart!.text).toContain("<hyperplan-mode>")
|
||||
expect(textPart!.text).toContain("<ultrawork-mode>")
|
||||
})
|
||||
|
||||
test("should block combo via intersection rule when disabled_keywords includes 'ultrawork'", async () => {
|
||||
// given - ultrawork standalone disabled, intersection rule cascades to combo
|
||||
const sessionID = "combo-intersection-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const toastCalls: string[] = []
|
||||
const hook = createKeywordDetectorHook(
|
||||
createMockPluginInput({ toastCalls }),
|
||||
undefined,
|
||||
undefined,
|
||||
{ disabled_keywords: ["ultrawork"] },
|
||||
)
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "hpp ulw plan stuff" }],
|
||||
}
|
||||
|
||||
// when - combo would match but is blocked via intersection
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - no combo, no ultrawork content leaks; standalone hyperplan still fires
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).not.toContain("<hyperplan-ultrawork-mode>")
|
||||
expect(textPart!.text).not.toContain("<ultrawork-mode>")
|
||||
expect(textPart!.text).toContain("<hyperplan-mode>")
|
||||
expect(toastCalls).not.toContain("Hyperplan Ultrawork Mode Activated")
|
||||
expect(toastCalls).not.toContain("Ultrawork Mode Activated")
|
||||
})
|
||||
|
||||
test("should allow combo in non-main session (passes through like standalone ultrawork)", async () => {
|
||||
// given - main session set, different (subagent) session triggers combo
|
||||
const mainSessionID = "main-combo"
|
||||
const subagentSessionID = "subagent-combo"
|
||||
setMainSession(mainSessionID)
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput())
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "hpp ulw run this" }],
|
||||
}
|
||||
|
||||
// when - subagent session triggers combo
|
||||
await hook["chat.message"]({ sessionID: subagentSessionID }, output)
|
||||
|
||||
// then - combo banner reaches non-main session (whitelisted alongside standalone ultrawork)
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toContain("<hyperplan-ultrawork-mode>")
|
||||
expect(textPart!.text).toContain("<ultrawork-mode>")
|
||||
expect(textPart!.text).toContain("run this")
|
||||
})
|
||||
|
||||
test("should filter combo when agent is prometheus (planner)", async () => {
|
||||
// given - planner agent receives a combo prompt
|
||||
const sessionID = "combo-prometheus-session"
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput())
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "hpp ulw plan stuff" }],
|
||||
}
|
||||
|
||||
// when - planner-agent path filters all execution-mode keywords
|
||||
await hook["chat.message"]({ sessionID, agent: "prometheus" }, output)
|
||||
|
||||
// then - text untouched: combo, ultrawork, and hyperplan all filtered for planner
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toBe("hpp ulw plan stuff")
|
||||
expect(textPart!.text).not.toContain("<hyperplan-ultrawork-mode>")
|
||||
expect(textPart!.text).not.toContain("<ultrawork-mode>")
|
||||
expect(textPart!.text).not.toContain("<hyperplan-mode>")
|
||||
})
|
||||
|
||||
test("should reuse ultrawork variant: combo with GPT model embeds GPT ultrawork content", async () => {
|
||||
// given - GPT-5.4 model selects the GPT ultrawork variant inside the combo banner
|
||||
const sessionID = "combo-gpt-variant-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput())
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "hpp ulw build feature" }],
|
||||
}
|
||||
|
||||
// when - combo fires with GPT model resolved
|
||||
await hook["chat.message"](
|
||||
{ sessionID, agent: "sisyphus", model: { providerID: "openai", modelID: "gpt-5.4" } },
|
||||
output,
|
||||
)
|
||||
|
||||
// then - combo banner present and GPT-variant ultrawork content embedded (output_verbosity_spec is GPT-only)
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toContain("<hyperplan-ultrawork-mode>")
|
||||
expect(textPart!.text).toContain("<output_verbosity_spec>")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,291 @@
|
||||
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { createKeywordDetectorHook } from "./index"
|
||||
import { setMainSession, _resetForTesting } from "../../features/claude-code-session-state"
|
||||
import * as sharedModule from "../../shared"
|
||||
import * as sessionState from "../../features/claude-code-session-state"
|
||||
|
||||
describe("keyword-detector hyperplan keyword", () => {
|
||||
let logSpy: ReturnType<typeof spyOn>
|
||||
let getMainSessionSpy: ReturnType<typeof spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
_resetForTesting()
|
||||
logSpy = spyOn(sharedModule, "log").mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
logSpy?.mockRestore()
|
||||
getMainSessionSpy?.mockRestore()
|
||||
_resetForTesting()
|
||||
})
|
||||
|
||||
function createMockPluginInput(options: { toastCalls?: string[] } = {}) {
|
||||
const toastCalls = options.toastCalls ?? []
|
||||
return {
|
||||
client: {
|
||||
tui: {
|
||||
showToast: async (opts: { body: { title: string } }) => {
|
||||
toastCalls.push(opts.body.title)
|
||||
},
|
||||
},
|
||||
},
|
||||
} as PluginInput
|
||||
}
|
||||
|
||||
test("should inject hyperplan message when user types 'hyperplan'", async () => {
|
||||
// given - main session typing the full keyword
|
||||
const sessionID = "hyperplan-full-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput())
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "hyperplan refactor the auth module" }],
|
||||
}
|
||||
|
||||
// when - keyword detection runs
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - hyperplan-mode wrapper and skill-loading instruction should be present
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toContain("<hyperplan-mode>")
|
||||
expect(textPart!.text).toContain('skill(name="hyperplan")')
|
||||
expect(textPart!.text).toContain("HYPERPLAN MODE ENABLED")
|
||||
expect(textPart!.text).toContain("unspecified-low")
|
||||
expect(textPart!.text).toContain("unspecified-high")
|
||||
expect(textPart!.text).toContain("artistry")
|
||||
expect(textPart!.text).toContain("ultrabrain")
|
||||
expect(textPart!.text).toContain("deep")
|
||||
expect(textPart!.text).toContain("only if")
|
||||
expect(textPart!.text).toContain("enabled")
|
||||
expect(textPart!.text).toContain("refactor the auth module")
|
||||
expect(textPart!.text).toContain("---")
|
||||
})
|
||||
|
||||
test("should inject hyperplan message when user types 'hpp' shorthand", async () => {
|
||||
// given - main session typing the short keyword
|
||||
const sessionID = "hyperplan-short-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput())
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "hpp how should I structure this feature" }],
|
||||
}
|
||||
|
||||
// when - keyword detection runs
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - hyperplan injection should fire
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toContain("<hyperplan-mode>")
|
||||
expect(textPart!.text).toContain('skill(name="hyperplan")')
|
||||
})
|
||||
|
||||
test("should inject hyperplan message case-insensitively", async () => {
|
||||
// given - main session typing in mixed case
|
||||
const sessionID = "hyperplan-case-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput())
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "HyperPlan something now" }],
|
||||
}
|
||||
|
||||
// when - keyword detection runs with mixed case input
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - hyperplan should still fire
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toContain("<hyperplan-mode>")
|
||||
})
|
||||
|
||||
test("should NOT trigger hyperplan when 'hpp' is a substring of another word", async () => {
|
||||
// given - text contains 'hpp' only as part of larger string with no word boundary
|
||||
const sessionID = "hyperplan-substring-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput())
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "myhppvar = 1" }],
|
||||
}
|
||||
|
||||
// when - keyword detection runs
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - hyperplan should NOT trigger because 'hpp' lacks word boundaries
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toBe("myhppvar = 1")
|
||||
expect(textPart!.text).not.toContain("<hyperplan-mode>")
|
||||
})
|
||||
|
||||
test("should fire 'Hyperplan Mode Activated' toast when keyword detected", async () => {
|
||||
// given - main session and toast tracking
|
||||
const sessionID = "hyperplan-toast-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const toastCalls: string[] = []
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput({ toastCalls }))
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "hyperplan this task" }],
|
||||
}
|
||||
|
||||
// when - hyperplan keyword fires
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - toast title should be present in tracked calls
|
||||
expect(toastCalls).toContain("Hyperplan Mode Activated")
|
||||
})
|
||||
|
||||
test("should NOT inject hyperplan when disabled_keywords includes 'hyperplan'", async () => {
|
||||
// given - keyword detector with hyperplan disabled
|
||||
const sessionID = "hyperplan-disabled-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const toastCalls: string[] = []
|
||||
const hook = createKeywordDetectorHook(
|
||||
createMockPluginInput({ toastCalls }),
|
||||
undefined,
|
||||
undefined,
|
||||
{ disabled_keywords: ["hyperplan"] },
|
||||
)
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "hyperplan refactor this" }],
|
||||
}
|
||||
|
||||
// when - hyperplan keyword would normally fire
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - neither injection nor toast should occur
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toBe("hyperplan refactor this")
|
||||
expect(textPart!.text).not.toContain("<hyperplan-mode>")
|
||||
expect(toastCalls).not.toContain("Hyperplan Mode Activated")
|
||||
})
|
||||
|
||||
test("should filter hyperplan keyword in non-main session (only ultrawork allowed there)", async () => {
|
||||
// given - main session set, different (subagent) session triggers hyperplan
|
||||
const mainSessionID = "main-hyperplan"
|
||||
const subagentSessionID = "subagent-hyperplan"
|
||||
setMainSession(mainSessionID)
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput())
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "hyperplan please" }],
|
||||
}
|
||||
|
||||
// when - subagent session triggers hyperplan keyword
|
||||
await hook["chat.message"]({ sessionID: subagentSessionID }, output)
|
||||
|
||||
// then - hyperplan injection should be skipped in non-main session
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toBe("hyperplan please")
|
||||
expect(textPart!.text).not.toContain("<hyperplan-mode>")
|
||||
})
|
||||
|
||||
test("should skip hyperplan injection when agent is prometheus (planner)", async () => {
|
||||
// given - hook running with prometheus agent and a prompt that only triggers hyperplan
|
||||
const sessionID = "hyperplan-prometheus-session"
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput())
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "hyperplan refactor stuff" }],
|
||||
}
|
||||
|
||||
// when - hyperplan keyword detected with prometheus agent
|
||||
await hook["chat.message"]({ sessionID, agent: "prometheus" }, output)
|
||||
|
||||
// then - hyperplan should be filtered out for planner agents
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).not.toContain("<hyperplan-mode>")
|
||||
expect(textPart!.text).not.toContain('skill(name="hyperplan")')
|
||||
expect(textPart!.text).toContain("hyperplan refactor stuff")
|
||||
})
|
||||
|
||||
test("should NOT inject hyperplan when user invokes /hyperplan slash command", async () => {
|
||||
// given - main session typing the slash command form
|
||||
const sessionID = "hyperplan-slash-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const toastCalls: string[] = []
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput({ toastCalls }))
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "/hyperplan refactor the auth module" }],
|
||||
}
|
||||
|
||||
// when - keyword detection runs on slash-command-prefixed text
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - the slash command path owns the message; keyword detector must not double-inject
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toBe("/hyperplan refactor the auth module")
|
||||
expect(textPart!.text).not.toContain("<hyperplan-mode>")
|
||||
expect(toastCalls).not.toContain("Hyperplan Mode Activated")
|
||||
})
|
||||
|
||||
test("should NOT inject hyperplan when user invokes /hpp shorthand slash command", async () => {
|
||||
// given - main session and shorthand slash command
|
||||
const sessionID = "hyperplan-slash-shorthand-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput())
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "/hpp investigate the build pipeline" }],
|
||||
}
|
||||
|
||||
// when - keyword detection runs
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - keyword detector should yield to the slash command system
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toBe("/hpp investigate the build pipeline")
|
||||
expect(textPart!.text).not.toContain("<hyperplan-mode>")
|
||||
})
|
||||
|
||||
test("should still inject hyperplan when slash appears mid-message (not a slash command)", async () => {
|
||||
// given - text contains a slash later but does not start with one
|
||||
const sessionID = "hyperplan-mid-slash-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput())
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "hyperplan: refactor src/auth/handler.ts" }],
|
||||
}
|
||||
|
||||
// when - keyword detection runs on free-form text that mentions hyperplan first
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - hyperplan should still fire (this is a real keyword invocation, not a slash command)
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toContain("<hyperplan-mode>")
|
||||
})
|
||||
|
||||
test("should skip hyperplan injection when agent name contains 'planner' token", async () => {
|
||||
// given - hook running with planner-named agent and a prompt that only triggers hpp
|
||||
const sessionID = "hyperplan-planner-session"
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput())
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "hpp build the feature" }],
|
||||
}
|
||||
|
||||
// when - hpp keyword detected with planner agent
|
||||
await hook["chat.message"]({ sessionID, agent: "Plan Agent" }, output)
|
||||
|
||||
// then - hyperplan should be filtered out
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).not.toContain("<hyperplan-mode>")
|
||||
expect(textPart!.text).not.toContain('skill(name="hyperplan")')
|
||||
expect(textPart!.text).toContain("hpp build the feature")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Hyperplan keyword detector.
|
||||
*
|
||||
* Triggers when the user wants adversarial multi-agent planning via team-mode.
|
||||
*
|
||||
* Triggers (case-insensitive, word-bounded):
|
||||
* - English: hyperplan, hpp
|
||||
*
|
||||
* The detector injects a thin wrapper that loads the `hyperplan` skill, which
|
||||
* carries the full orchestration instructions for the 5-member adversarial team.
|
||||
*/
|
||||
|
||||
export const HYPERPLAN_PATTERN = /\b(hyperplan|hpp)\b/i
|
||||
|
||||
export const HYPERPLAN_MESSAGE = `<hyperplan-mode>
|
||||
**MANDATORY**: Say "HYPERPLAN MODE ENABLED!" as your first response, exactly once.
|
||||
|
||||
The user invoked **hyperplan mode** — adversarial multi-agent planning via team-mode.
|
||||
|
||||
LOAD THE HYPERPLAN SKILL IMMEDIATELY:
|
||||
|
||||
\`\`\`
|
||||
skill(name="hyperplan")
|
||||
\`\`\`
|
||||
|
||||
After loading, follow the skill's full workflow EXACTLY:
|
||||
1. Acknowledge and capture the planning request
|
||||
2. Spawn the adversarial team via \`team_create\` with category members \`unspecified-low\`, \`unspecified-high\`, \`ultrabrain\`, and \`artistry\`; include \`deep\` only if the category is enabled
|
||||
3. Round 1 — Independent analysis (each member produces findings)
|
||||
4. Round 2 — Cross-attack (each member ruthlessly attacks the other 4's findings)
|
||||
5. Round 3 — Defend, refine, or concede
|
||||
6. Distill defensible insights into a structured bundle (Lead does NOT write the plan)
|
||||
7. MANDATORY: hand the bundle to the \`plan\` agent via \`task(subagent_type="plan", ...)\` — the plan agent owns sequencing, parallelization, and verification gates
|
||||
8. Present the plan agent's output verbatim with provenance line, then clean up the team
|
||||
|
||||
Do NOT improvise. Do NOT skip rounds. Do NOT write the plan yourself in step 6 — the handoff to the plan agent in step 7 is non-negotiable. Be the lead orchestrator and let the adversarial members do the cross-critique.
|
||||
|
||||
If team-mode is unavailable (\`team_*\` tools missing), instruct the user to set \`team_mode.enabled: true\` in \`~/.config/opencode/oh-my-opencode.jsonc\` and restart opencode.
|
||||
</hyperplan-mode>`
|
||||
@@ -0,0 +1 @@
|
||||
export { HYPERPLAN_PATTERN, HYPERPLAN_MESSAGE } from "./default"
|
||||
@@ -860,3 +860,418 @@ describe("keyword-detector non-OMO agent skipping", () => {
|
||||
expect(textPart!.text).not.toContain("[search-mode]")
|
||||
})
|
||||
})
|
||||
|
||||
describe("keyword-detector team mode", () => {
|
||||
let logCalls: Array<{ msg: string; data?: unknown }>
|
||||
let logSpy: ReturnType<typeof spyOn>
|
||||
let getMainSessionSpy: ReturnType<typeof spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
_resetForTesting()
|
||||
logCalls = []
|
||||
logSpy = spyOn(sharedModule, "log").mockImplementation((msg: string, data?: unknown) => {
|
||||
logCalls.push({ msg, data })
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
logSpy?.mockRestore()
|
||||
getMainSessionSpy?.mockRestore()
|
||||
_resetForTesting()
|
||||
})
|
||||
|
||||
function createMockPluginInput() {
|
||||
return {
|
||||
client: {
|
||||
tui: {
|
||||
showToast: async () => {},
|
||||
},
|
||||
},
|
||||
} as unknown as PluginInput
|
||||
}
|
||||
|
||||
test("should inject team-mode message when user types 'team mode'", async () => {
|
||||
// given - main session typing English 'team mode'
|
||||
const collector = new ContextCollector()
|
||||
const sessionID = "team-en-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "let's use team mode for this task" }],
|
||||
}
|
||||
|
||||
// when - keyword detection runs
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - team-mode message should be prepended with team_* tool guidance
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toContain("[team-mode]")
|
||||
expect(textPart!.text).toContain("team_create")
|
||||
expect(textPart!.text).toContain("team_task_create")
|
||||
expect(textPart!.text).toContain("team_send_message")
|
||||
expect(textPart!.text).toContain("NEVER substitute with delegate_task")
|
||||
expect(textPart!.text).toContain("for this task")
|
||||
})
|
||||
|
||||
test("should inject team-mode message when user types '팀 모드' (Korean with space)", async () => {
|
||||
// given - main session typing Korean '팀 모드'
|
||||
const collector = new ContextCollector()
|
||||
const sessionID = "team-ko-spaced-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "이거 팀 모드로 해줘" }],
|
||||
}
|
||||
|
||||
// when - keyword detection runs
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - team-mode message should be prepended
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toContain("[team-mode]")
|
||||
expect(textPart!.text).toContain("팀 모드로 해줘")
|
||||
})
|
||||
|
||||
test("should inject team-mode message when user types '팀으로'", async () => {
|
||||
// given - main session typing Korean '팀으로'
|
||||
const collector = new ContextCollector()
|
||||
const sessionID = "team-ko-eulo-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "팀으로 일하자" }],
|
||||
}
|
||||
|
||||
// when - keyword detection runs
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - team-mode message should be prepended
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toContain("[team-mode]")
|
||||
expect(textPart!.text).toContain("팀으로 일하자")
|
||||
})
|
||||
|
||||
test("should NOT trigger team-mode on '스팀으로' (false-positive guard)", async () => {
|
||||
// given - text contains '팀으로' as substring of another Korean word ('스팀으로')
|
||||
const collector = new ContextCollector()
|
||||
const sessionID = "false-positive-eulo-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "스팀으로 게임 켜줘" }],
|
||||
}
|
||||
|
||||
// when - keyword detection runs
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - team-mode should NOT be triggered, text unchanged
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toBe("스팀으로 게임 켜줘")
|
||||
expect(textPart!.text).not.toContain("[team-mode]")
|
||||
})
|
||||
|
||||
test("should NOT trigger team-mode on '스팀모드' (Hangul-prefix false-positive guard)", async () => {
|
||||
// given - text contains '팀모드' as substring of another Korean word ('스팀모드')
|
||||
const collector = new ContextCollector()
|
||||
const sessionID = "false-positive-mode-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "스팀모드 활성화" }],
|
||||
}
|
||||
|
||||
// when - keyword detection runs
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - team-mode should NOT be triggered
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toBe("스팀모드 활성화")
|
||||
expect(textPart!.text).not.toContain("[team-mode]")
|
||||
})
|
||||
|
||||
test("should NOT trigger team-mode on bare 'team' without 'mode'", async () => {
|
||||
// given - text contains 'team' but not 'team mode'
|
||||
const collector = new ContextCollector()
|
||||
const sessionID = "bare-team-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "join the team and start working" }],
|
||||
}
|
||||
|
||||
// when - keyword detection runs
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - team-mode should NOT be triggered
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).not.toContain("[team-mode]")
|
||||
})
|
||||
|
||||
test("should filter team-mode keyword in non-main session (only ultrawork allowed there)", async () => {
|
||||
// given - main session set, different (subagent) session triggers team mode
|
||||
const mainSessionID = "main-team-mode"
|
||||
const subagentSessionID = "subagent-team-mode"
|
||||
setMainSession(mainSessionID)
|
||||
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput())
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "team mode please" }],
|
||||
}
|
||||
|
||||
// when - subagent session triggers team mode keyword
|
||||
await hook["chat.message"]({ sessionID: subagentSessionID }, output)
|
||||
|
||||
// then - team-mode message should NOT be injected in subagent session
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toBe("team mode please")
|
||||
expect(textPart!.text).not.toContain("[team-mode]")
|
||||
})
|
||||
})
|
||||
|
||||
describe("keyword-detector disabled_keywords config", () => {
|
||||
let logCalls: Array<{ msg: string; data?: unknown }>
|
||||
let logSpy: ReturnType<typeof spyOn>
|
||||
let getMainSessionSpy: ReturnType<typeof spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
_resetForTesting()
|
||||
logCalls = []
|
||||
logSpy = spyOn(sharedModule, "log").mockImplementation((msg: string, data?: unknown) => {
|
||||
logCalls.push({ msg, data })
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
logSpy?.mockRestore()
|
||||
getMainSessionSpy?.mockRestore()
|
||||
_resetForTesting()
|
||||
})
|
||||
|
||||
function createMockPluginInput(options: { toastCalls?: string[] } = {}) {
|
||||
const toastCalls = options.toastCalls ?? []
|
||||
return {
|
||||
client: {
|
||||
tui: {
|
||||
showToast: async (opts: { body: { title: string } }) => {
|
||||
toastCalls.push(opts.body.title)
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as PluginInput
|
||||
}
|
||||
|
||||
test("should NOT inject search-mode when disabled_keywords includes 'search'", async () => {
|
||||
// given - keyword detector with search disabled
|
||||
const sessionID = "search-disabled-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(
|
||||
createMockPluginInput(),
|
||||
undefined,
|
||||
undefined,
|
||||
{ disabled_keywords: ["search"] },
|
||||
)
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "search for the bug in the code" }],
|
||||
}
|
||||
|
||||
// when - search keyword would normally trigger
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - search-mode injection should be skipped
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toBe("search for the bug in the code")
|
||||
expect(textPart!.text).not.toContain("[search-mode]")
|
||||
})
|
||||
|
||||
test("should NOT inject analyze-mode when disabled_keywords includes 'analyze'", async () => {
|
||||
// given - keyword detector with analyze disabled
|
||||
const sessionID = "analyze-disabled-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(
|
||||
createMockPluginInput(),
|
||||
undefined,
|
||||
undefined,
|
||||
{ disabled_keywords: ["analyze"] },
|
||||
)
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "how to do this" }],
|
||||
}
|
||||
|
||||
// when - analyze keyword would normally trigger
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - analyze-mode injection should be skipped
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toBe("how to do this")
|
||||
expect(textPart!.text).not.toContain("[analyze-mode]")
|
||||
})
|
||||
|
||||
test("should NOT inject team-mode when disabled_keywords includes 'team'", async () => {
|
||||
// given - keyword detector with team disabled
|
||||
const sessionID = "team-disabled-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(
|
||||
createMockPluginInput(),
|
||||
undefined,
|
||||
undefined,
|
||||
{ disabled_keywords: ["team"] },
|
||||
)
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "let's use team mode for this" }],
|
||||
}
|
||||
|
||||
// when - team keyword would normally trigger
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - team-mode injection should be skipped
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toBe("let's use team mode for this")
|
||||
expect(textPart!.text).not.toContain("[team-mode]")
|
||||
})
|
||||
|
||||
test("should NOT inject ultrawork message AND not show toast when disabled_keywords includes 'ultrawork'", async () => {
|
||||
// given - keyword detector with ultrawork disabled
|
||||
const sessionID = "ultrawork-disabled-session"
|
||||
const toastCalls: string[] = []
|
||||
const hook = createKeywordDetectorHook(
|
||||
createMockPluginInput({ toastCalls }),
|
||||
undefined,
|
||||
undefined,
|
||||
{ disabled_keywords: ["ultrawork"] },
|
||||
)
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "ultrawork do this task" }],
|
||||
}
|
||||
|
||||
// when - ultrawork keyword would normally trigger toast + injection
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - neither toast nor injection should occur
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toBe("ultrawork do this task")
|
||||
expect(textPart!.text).not.toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS")
|
||||
expect(toastCalls).not.toContain("Ultrawork Mode Activated")
|
||||
})
|
||||
|
||||
test("should disable multiple keywords simultaneously when listed together", async () => {
|
||||
// given - keyword detector with both search and analyze disabled
|
||||
const sessionID = "multi-disabled-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(
|
||||
createMockPluginInput(),
|
||||
undefined,
|
||||
undefined,
|
||||
{ disabled_keywords: ["search", "analyze"] },
|
||||
)
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "search and analyze the codebase" }],
|
||||
}
|
||||
|
||||
// when - both search and analyze would normally fire
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - neither mode should inject
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toBe("search and analyze the codebase")
|
||||
expect(textPart!.text).not.toContain("[search-mode]")
|
||||
expect(textPart!.text).not.toContain("[analyze-mode]")
|
||||
})
|
||||
|
||||
test("should let other keywords through when only one is disabled", async () => {
|
||||
// given - keyword detector with only search disabled, but message contains both search and analyze triggers
|
||||
const sessionID = "partial-disabled-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(
|
||||
createMockPluginInput(),
|
||||
undefined,
|
||||
undefined,
|
||||
{ disabled_keywords: ["search"] },
|
||||
)
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "search and analyze the codebase" }],
|
||||
}
|
||||
|
||||
// when - both keywords match but only search is disabled
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - analyze should still inject, search should be skipped
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).not.toContain("[search-mode]")
|
||||
expect(textPart!.text).toContain("[analyze-mode]")
|
||||
expect(textPart!.text).toContain("search and analyze the codebase")
|
||||
})
|
||||
|
||||
test("should behave normally (all keywords enabled) when config is undefined", async () => {
|
||||
// given - keyword detector with no config (regression test for backward compat)
|
||||
const sessionID = "no-config-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(
|
||||
createMockPluginInput(),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "search for the answer" }],
|
||||
}
|
||||
|
||||
// when - search keyword fires with no config
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - search-mode should inject as usual
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toContain("[search-mode]")
|
||||
})
|
||||
|
||||
test("should behave normally when disabled_keywords is an empty array", async () => {
|
||||
// given - keyword detector with empty disable list
|
||||
const sessionID = "empty-disabled-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(
|
||||
createMockPluginInput(),
|
||||
undefined,
|
||||
undefined,
|
||||
{ disabled_keywords: [] },
|
||||
)
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "investigate this issue" }],
|
||||
}
|
||||
|
||||
// when - analyze keyword fires with empty disable list
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - analyze-mode should still inject
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toContain("[analyze-mode]")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Team mode keyword detector.
|
||||
*
|
||||
* Triggers when the user explicitly invokes team-mode work:
|
||||
* - English: team mode, team-mode, team_mode, teammode (case-insensitive)
|
||||
* - Korean: 팀 모드, 팀모드, 팀으로
|
||||
*
|
||||
* The Korean variants use a negative lookbehind on Hangul syllables (가-힣)
|
||||
* to prevent false positives like "스팀으로" matching "팀으로", or
|
||||
* "스팀모드" matching "팀모드".
|
||||
*/
|
||||
|
||||
export const TEAM_PATTERN =
|
||||
/\bteam[\s_-]?mode\b|(?<![가-힣])(?:팀\s*모드|팀으로)/i
|
||||
|
||||
export const TEAM_MESSAGE = `[team-mode]
|
||||
Team mode reference detected. If user wants team-mode work, MUST orchestrate via team_* tools (team_create -> team_task_create + team_send_message). NEVER substitute with delegate_task - it is not equivalent. If team_* tools are unavailable (team_mode disabled in config), instruct user to set team_mode.enabled=true and restart opencode.`
|
||||
@@ -0,0 +1 @@
|
||||
export { TEAM_PATTERN, TEAM_MESSAGE } from "./default"
|
||||
@@ -163,7 +163,7 @@ describe("model fallback hook", () => {
|
||||
|
||||
expect(secondOutput.message["model"]).toEqual({
|
||||
providerID: "opencode-go",
|
||||
modelID: "kimi-k2.5",
|
||||
modelID: "kimi-k2.6",
|
||||
})
|
||||
expect(secondOutput.message["variant"]).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# src/hooks/ralph-loop/ — Self-Referential Dev Loop
|
||||
|
||||
**Generated:** 2026-04-11
|
||||
**Generated:** 2026-05-08
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -304,6 +304,54 @@ describe("ralph-loop", () => {
|
||||
expect(state?.iteration).toBe(2)
|
||||
})
|
||||
|
||||
test("should settle idle before injecting continuation", async () => {
|
||||
// given - active loop state with a configured idle settle delay
|
||||
const hook = createRalphLoopHook(createMockPluginInput(), { idleSettleMs: 25 })
|
||||
hook.startLoop("session-123", "Build a feature", { maxIterations: 10 })
|
||||
|
||||
// when - session goes idle
|
||||
const eventPromise = hook.event({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-123" },
|
||||
},
|
||||
})
|
||||
await Promise.resolve()
|
||||
|
||||
// then - continuation should not be injected in the same event-loop turn
|
||||
expect(promptCalls.length).toBe(0)
|
||||
|
||||
await eventPromise
|
||||
expect(promptCalls.length).toBe(1)
|
||||
expect(promptCalls[0].sessionID).toBe("session-123")
|
||||
})
|
||||
|
||||
test("#given hanging toast #when session idles #then continuation still injects", async () => {
|
||||
// given - TUI toast never settles
|
||||
const ctx = createMockPluginInput()
|
||||
ctx.client.tui = {
|
||||
showToast: () => new Promise(() => {}),
|
||||
} as never
|
||||
const hook = createRalphLoopHook(ctx, { idleSettleMs: 0 })
|
||||
hook.startLoop("session-123", "Build a feature", { maxIterations: 10 })
|
||||
|
||||
// when - session goes idle
|
||||
const result = await Promise.race([
|
||||
hook.event({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-123" },
|
||||
},
|
||||
}).then(() => "resolved" as const),
|
||||
new Promise<"timed-out">((resolvePromise) => setTimeout(() => resolvePromise("timed-out"), 50)),
|
||||
])
|
||||
|
||||
// then - continuation is not blocked by toast delivery
|
||||
expect(result).toBe("resolved")
|
||||
expect(promptCalls.length).toBe(1)
|
||||
expect(promptCalls[0].sessionID).toBe("session-123")
|
||||
})
|
||||
|
||||
test("should skip continuation when background task is running", async () => {
|
||||
// given - active loop state with a running background task
|
||||
const hook = createRalphLoopHook(createMockPluginInput(), {
|
||||
@@ -333,7 +381,7 @@ describe("ralph-loop", () => {
|
||||
|
||||
test("should stop loop when max iterations reached", async () => {
|
||||
// given - loop at max iteration
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
const hook = createRalphLoopHook(createMockPluginInput(), { idleSettleMs: 0 })
|
||||
hook.startLoop("session-123", "Build something", { maxIterations: 2 })
|
||||
|
||||
const state = hook.getState()!
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
type SessionState = {
|
||||
isRecovering?: boolean
|
||||
}
|
||||
|
||||
export function createLoopSessionRecovery(options?: { recoveryWindowMs?: number }) {
|
||||
const recoveryWindowMs = options?.recoveryWindowMs ?? 5000
|
||||
const sessions = new Map<string, SessionState>()
|
||||
|
||||
function getSessionState(sessionID: string): SessionState {
|
||||
let state = sessions.get(sessionID)
|
||||
if (!state) {
|
||||
state = {}
|
||||
sessions.set(sessionID, state)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
return {
|
||||
isRecovering(sessionID: string): boolean {
|
||||
return getSessionState(sessionID).isRecovering === true
|
||||
},
|
||||
markRecovering(sessionID: string): void {
|
||||
const state = getSessionState(sessionID)
|
||||
state.isRecovering = true
|
||||
setTimeout(() => {
|
||||
state.isRecovering = false
|
||||
}, recoveryWindowMs)
|
||||
},
|
||||
clear(sessionID: string): void {
|
||||
sessions.delete(sessionID)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ describe("ralph-loop non-abort error continuation", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("continues on next idle after non-abort session error", async () => {
|
||||
test("continues immediately after non-abort session error", async () => {
|
||||
// given - an active Ralph Loop receives a recoverable command error
|
||||
const hook = createRalphLoopHook({
|
||||
directory: testDirectory,
|
||||
@@ -81,16 +81,258 @@ describe("ralph-loop non-abort error continuation", () => {
|
||||
},
|
||||
})
|
||||
|
||||
// when - OpenCode emits the idle event caused by that failed command
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
|
||||
// then - the loop should continue instead of skipping idle as recovery
|
||||
// then - the loop should continue without waiting for a later idle event
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
expect(promptCalls[0]?.sessionID).toBe("session-123")
|
||||
expect(promptCalls[0]?.text).toContain("Keep working")
|
||||
expect(messagesCalls.length).toBeGreaterThan(0)
|
||||
expect(hook.getState()?.iteration).toBe(2)
|
||||
})
|
||||
test("continues ultrawork loop immediately after non-abort session error", async () => {
|
||||
// given - an active ULW Loop receives a recoverable runtime error
|
||||
const hook = createRalphLoopHook({
|
||||
directory: testDirectory,
|
||||
project: testDirectory,
|
||||
worktree: testDirectory,
|
||||
serverUrl: "http://localhost:4096",
|
||||
$: async () => ({}),
|
||||
client: {
|
||||
session: {
|
||||
messages: async (options: { path: { id: string } }) => {
|
||||
messagesCalls.push({ sessionID: options.path.id })
|
||||
return { data: [] }
|
||||
},
|
||||
promptAsync: async (options: {
|
||||
path: { id: string }
|
||||
body: { parts: Array<{ type: string; text: string }> }
|
||||
}) => {
|
||||
promptCalls.push({
|
||||
sessionID: options.path.id,
|
||||
text: options.body.parts[0]?.text ?? "",
|
||||
})
|
||||
return {}
|
||||
},
|
||||
prompt: async () => ({}),
|
||||
},
|
||||
tui: {
|
||||
showToast: async () => ({}),
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
|
||||
hook.startLoop("session-123", "Keep ultraworking", {
|
||||
messageCountAtStart: 0,
|
||||
maxIterations: 5,
|
||||
ultrawork: true,
|
||||
})
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID: "session-123",
|
||||
error: { name: "RuntimeError" },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// then - the ULW continuation keeps the ultrawork directive
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
expect(promptCalls[0]?.sessionID).toBe("session-123")
|
||||
expect(promptCalls[0]?.text).toMatch(/^ultrawork /)
|
||||
expect(promptCalls[0]?.text).toContain("Keep ultraworking")
|
||||
expect(hook.getState()?.iteration).toBe(2)
|
||||
})
|
||||
|
||||
test("continues after retry run activity when no stale idle arrived", async () => {
|
||||
// given - an active loop retries a recoverable runtime error
|
||||
const hook = createRalphLoopHook({
|
||||
directory: testDirectory,
|
||||
project: testDirectory,
|
||||
worktree: testDirectory,
|
||||
serverUrl: "http://localhost:4096",
|
||||
$: async () => ({}),
|
||||
client: {
|
||||
session: {
|
||||
messages: async (options: { path: { id: string } }) => {
|
||||
messagesCalls.push({ sessionID: options.path.id })
|
||||
return { data: [] }
|
||||
},
|
||||
promptAsync: async (options: {
|
||||
path: { id: string }
|
||||
body: { parts: Array<{ type: string; text: string }> }
|
||||
}) => {
|
||||
promptCalls.push({
|
||||
sessionID: options.path.id,
|
||||
text: options.body.parts[0]?.text ?? "",
|
||||
})
|
||||
return {}
|
||||
},
|
||||
prompt: async () => ({}),
|
||||
},
|
||||
tui: {
|
||||
showToast: async () => ({}),
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
|
||||
hook.startLoop("session-123", "Keep working", {
|
||||
messageCountAtStart: 0,
|
||||
maxIterations: 5,
|
||||
})
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID: "session-123",
|
||||
error: { name: "RuntimeError" },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// when - the retried run emits real assistant activity before any stale idle
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "message.part.delta",
|
||||
properties: {
|
||||
sessionID: "session-123",
|
||||
messageID: "msg-1",
|
||||
partID: "part-1",
|
||||
field: "text",
|
||||
delta: "working",
|
||||
},
|
||||
},
|
||||
})
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
|
||||
// then - the real idle is allowed to continue the loop
|
||||
expect(promptCalls).toHaveLength(2)
|
||||
expect(hook.getState()?.iteration).toBe(3)
|
||||
})
|
||||
|
||||
test("skips immediate runtime retry while background tasks are running", async () => {
|
||||
// given - an active loop owns running background work
|
||||
const hook = createRalphLoopHook({
|
||||
directory: testDirectory,
|
||||
project: testDirectory,
|
||||
worktree: testDirectory,
|
||||
serverUrl: "http://localhost:4096",
|
||||
$: async () => ({}),
|
||||
client: {
|
||||
session: {
|
||||
messages: async (options: { path: { id: string } }) => {
|
||||
messagesCalls.push({ sessionID: options.path.id })
|
||||
return { data: [] }
|
||||
},
|
||||
promptAsync: async (options: {
|
||||
path: { id: string }
|
||||
body: { parts: Array<{ type: string; text: string }> }
|
||||
}) => {
|
||||
promptCalls.push({
|
||||
sessionID: options.path.id,
|
||||
text: options.body.parts[0]?.text ?? "",
|
||||
})
|
||||
return {}
|
||||
},
|
||||
prompt: async () => ({}),
|
||||
},
|
||||
tui: {
|
||||
showToast: async () => ({}),
|
||||
},
|
||||
},
|
||||
} as never, {
|
||||
backgroundManager: {
|
||||
getTasksByParentSession: (sessionID: string) => sessionID === "session-123"
|
||||
? [{ status: "running" }]
|
||||
: [],
|
||||
},
|
||||
})
|
||||
|
||||
hook.startLoop("session-123", "Keep working", {
|
||||
messageCountAtStart: 0,
|
||||
maxIterations: 5,
|
||||
})
|
||||
|
||||
// when - the same session reports a recoverable runtime error
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID: "session-123",
|
||||
error: { name: "RuntimeError" },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// then - Ralph waits for background work instead of starting overlapping continuation
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
expect(hook.getState()?.iteration).toBe(1)
|
||||
})
|
||||
|
||||
test("stops retrying runtime errors after max iterations", async () => {
|
||||
// given - an active Ralph Loop has one retry remaining
|
||||
const hook = createRalphLoopHook({
|
||||
directory: testDirectory,
|
||||
project: testDirectory,
|
||||
worktree: testDirectory,
|
||||
serverUrl: "http://localhost:4096",
|
||||
$: async () => ({}),
|
||||
client: {
|
||||
session: {
|
||||
messages: async (options: { path: { id: string } }) => {
|
||||
messagesCalls.push({ sessionID: options.path.id })
|
||||
return { data: [] }
|
||||
},
|
||||
promptAsync: async (options: {
|
||||
path: { id: string }
|
||||
body: { parts: Array<{ type: string; text: string }> }
|
||||
}) => {
|
||||
promptCalls.push({
|
||||
sessionID: options.path.id,
|
||||
text: options.body.parts[0]?.text ?? "",
|
||||
})
|
||||
return {}
|
||||
},
|
||||
prompt: async () => ({}),
|
||||
},
|
||||
tui: {
|
||||
showToast: async () => ({}),
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
|
||||
hook.startLoop("session-123", "Keep working", {
|
||||
messageCountAtStart: 0,
|
||||
maxIterations: 2,
|
||||
})
|
||||
|
||||
// when - the first runtime error consumes the final allowed attempt
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID: "session-123",
|
||||
error: { name: "RuntimeError" },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// when - another runtime error arrives after the retry budget is exhausted
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID: "session-123",
|
||||
error: { name: "RuntimeError" },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// then - the loop does not exceed the configured retry count
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
expect(hook.getState()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,16 +20,107 @@ type LoopStateController = {
|
||||
setVerificationSessionID: (sessionID: string, verificationSessionID: string) => RalphLoopState | null
|
||||
restartAfterFailedVerification: (sessionID: string, messageCountAtStart?: number) => RalphLoopState | null
|
||||
}
|
||||
type RalphLoopEventHandlerOptions = { directory: string; apiTimeoutMs: number; getTranscriptPath: (sessionID: string) => string | undefined; checkSessionExists?: RalphLoopOptions["checkSessionExists"]; backgroundManager?: RalphLoopOptions["backgroundManager"]; loopState: LoopStateController }
|
||||
type RalphLoopEventHandlerOptions = { directory: string; apiTimeoutMs: number; idleSettleMs: number; getTranscriptPath: (sessionID: string) => string | undefined; checkSessionExists?: RalphLoopOptions["checkSessionExists"]; backgroundManager?: RalphLoopOptions["backgroundManager"]; loopState: LoopStateController }
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve()
|
||||
}
|
||||
|
||||
function hasRunningBackgroundTasks(
|
||||
backgroundManager: RalphLoopOptions["backgroundManager"],
|
||||
sessionID: string,
|
||||
): boolean {
|
||||
return backgroundManager
|
||||
? backgroundManager.getTasksByParentSession(sessionID).some((task: { status: string }) => task.status === "running")
|
||||
: false
|
||||
}
|
||||
|
||||
function getInfoSessionID(props: Record<string, unknown> | undefined): string | undefined {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = info?.sessionID
|
||||
return typeof sessionID === "string" ? sessionID : undefined
|
||||
}
|
||||
|
||||
function getRuntimeRetryActivitySessionID(
|
||||
eventType: string,
|
||||
props: Record<string, unknown> | undefined,
|
||||
): string | undefined {
|
||||
if (eventType === "message.updated") {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const role = info?.role
|
||||
return role === "assistant" ? getInfoSessionID(props) : undefined
|
||||
}
|
||||
|
||||
if (eventType === "message.part.updated") {
|
||||
if (typeof props?.sessionID === "string") return props.sessionID
|
||||
return getInfoSessionID(props)
|
||||
}
|
||||
|
||||
if (eventType === "message.part.delta") {
|
||||
return typeof props?.sessionID === "string" ? props.sessionID : undefined
|
||||
}
|
||||
|
||||
if (eventType === "tool.execute.before" || eventType === "tool.execute.after") {
|
||||
return typeof props?.sessionID === "string" ? props.sessionID : undefined
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return typeof error === "object"
|
||||
&& error !== null
|
||||
&& "name" in error
|
||||
&& (error as { name?: unknown }).name === "MessageAbortedError"
|
||||
}
|
||||
|
||||
function showToastBestEffort(
|
||||
ctx: PluginInput,
|
||||
body: { title: string; message: string; variant: "warning" | "info"; duration: number },
|
||||
): void {
|
||||
try {
|
||||
void Promise.resolve(ctx.client.tui?.showToast?.({ body })).catch(() => {})
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function showMaxIterationsToast(
|
||||
ctx: PluginInput,
|
||||
state: RalphLoopState,
|
||||
): void {
|
||||
showToastBestEffort(ctx, {
|
||||
title: "Ralph Loop Stopped",
|
||||
message: `Max iterations (${state.max_iterations}) reached without completion`,
|
||||
variant: "warning",
|
||||
duration: 5000,
|
||||
})
|
||||
}
|
||||
|
||||
function showIterationToast(
|
||||
ctx: PluginInput,
|
||||
state: RalphLoopState,
|
||||
): void {
|
||||
showToastBestEffort(ctx, {
|
||||
title: "Ralph Loop",
|
||||
message: `Iteration ${state.iteration}/${typeof state.max_iterations === "number" ? state.max_iterations : "unbounded"}`,
|
||||
variant: "info",
|
||||
duration: 2000,
|
||||
})
|
||||
}
|
||||
|
||||
export function createRalphLoopEventHandler(
|
||||
ctx: PluginInput,
|
||||
options: RalphLoopEventHandlerOptions,
|
||||
) {
|
||||
const inFlightSessions = new Set<string>()
|
||||
const runtimeErrorRetriedSessions = new Map<string, number>()
|
||||
|
||||
return async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
const runtimeRetryActivitySessionID = getRuntimeRetryActivitySessionID(event.type, props)
|
||||
if (runtimeRetryActivitySessionID) {
|
||||
runtimeErrorRetriedSessions.delete(runtimeRetryActivitySessionID)
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
@@ -44,18 +135,14 @@ export function createRalphLoopEventHandler(
|
||||
|
||||
try {
|
||||
const state = options.loopState.getState()
|
||||
if (!state || !state.active) {
|
||||
return
|
||||
}
|
||||
if (!state || !state.active) {
|
||||
return
|
||||
}
|
||||
|
||||
const hasRunningBackgroundTasks = options.backgroundManager
|
||||
? options.backgroundManager.getTasksByParentSession(sessionID).some((task: { status: string }) => task.status === "running")
|
||||
: false
|
||||
|
||||
if (hasRunningBackgroundTasks) {
|
||||
log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID })
|
||||
return
|
||||
}
|
||||
if (hasRunningBackgroundTasks(options.backgroundManager, sessionID)) {
|
||||
log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const verificationSessionID = state.verification_pending
|
||||
? state.verification_session_id
|
||||
@@ -121,6 +208,7 @@ export function createRalphLoopEventHandler(
|
||||
})
|
||||
|
||||
if (completionViaTranscript || completionViaApi) {
|
||||
runtimeErrorRetriedSessions.delete(sessionID)
|
||||
log(`[${HOOK_NAME}] Completion detected!`, {
|
||||
sessionID,
|
||||
iteration: state.iteration,
|
||||
@@ -160,6 +248,15 @@ export function createRalphLoopEventHandler(
|
||||
return
|
||||
}
|
||||
|
||||
if (runtimeErrorRetriedSessions.get(sessionID) === state.iteration) {
|
||||
runtimeErrorRetriedSessions.delete(sessionID)
|
||||
log(`[${HOOK_NAME}] Skipped stale idle after runtime error retry`, {
|
||||
sessionID,
|
||||
iteration: state.iteration,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
typeof state.max_iterations === "number"
|
||||
&& state.iteration >= state.max_iterations
|
||||
@@ -171,9 +268,7 @@ export function createRalphLoopEventHandler(
|
||||
})
|
||||
options.loopState.clear()
|
||||
|
||||
await ctx.client.tui?.showToast?.({
|
||||
body: { title: "Ralph Loop Stopped", message: `Max iterations (${state.max_iterations}) reached without completion`, variant: "warning", duration: 5000 },
|
||||
}).catch(() => {})
|
||||
showMaxIterationsToast(ctx, state)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -189,14 +284,8 @@ export function createRalphLoopEventHandler(
|
||||
max: newState.max_iterations,
|
||||
})
|
||||
|
||||
await ctx.client.tui?.showToast?.({
|
||||
body: {
|
||||
title: "Ralph Loop",
|
||||
message: `Iteration ${newState.iteration}/${typeof newState.max_iterations === "number" ? newState.max_iterations : "unbounded"}`,
|
||||
variant: "info",
|
||||
duration: 2000,
|
||||
},
|
||||
}).catch(() => {})
|
||||
showIterationToast(ctx, newState)
|
||||
await sleep(options.idleSettleMs)
|
||||
|
||||
try {
|
||||
await continueIteration(ctx, newState, {
|
||||
@@ -223,7 +312,100 @@ export function createRalphLoopEventHandler(
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
handleErroredLoopSession(props, options.loopState)
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const error = props?.error
|
||||
if (!sessionID || isAbortError(error)) {
|
||||
handleErroredLoopSession(props, options.loopState)
|
||||
return
|
||||
}
|
||||
|
||||
if (inFlightSessions.has(sessionID)) {
|
||||
log(`[${HOOK_NAME}] Skipped runtime error retry: handler in flight`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
inFlightSessions.add(sessionID)
|
||||
try {
|
||||
const state = options.loopState.getState()
|
||||
if (!state || !state.active) {
|
||||
handleErroredLoopSession(props, options.loopState)
|
||||
return
|
||||
}
|
||||
|
||||
const verificationSessionID = state.verification_pending
|
||||
? state.verification_session_id
|
||||
: undefined
|
||||
const matchesParentSession = state.session_id === undefined || state.session_id === sessionID
|
||||
const matchesVerificationSession = verificationSessionID === sessionID
|
||||
if (!matchesParentSession && !matchesVerificationSession) {
|
||||
handleErroredLoopSession(props, options.loopState)
|
||||
return
|
||||
}
|
||||
|
||||
if (hasRunningBackgroundTasks(options.backgroundManager, sessionID)) {
|
||||
log(`[${HOOK_NAME}] Skipped runtime error retry: background tasks running`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
log(`[${HOOK_NAME}] Retrying after runtime session error`, {
|
||||
sessionID,
|
||||
iteration: state.iteration,
|
||||
error: String(error),
|
||||
})
|
||||
|
||||
if (state.verification_pending) {
|
||||
await handlePendingVerification(ctx, {
|
||||
sessionID,
|
||||
state,
|
||||
verificationSessionID,
|
||||
matchesParentSession,
|
||||
matchesVerificationSession,
|
||||
loopState: options.loopState,
|
||||
directory: options.directory,
|
||||
apiTimeoutMs: options.apiTimeoutMs,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
typeof state.max_iterations === "number"
|
||||
&& state.iteration >= state.max_iterations
|
||||
) {
|
||||
log(`[${HOOK_NAME}] Runtime error retry budget exhausted`, {
|
||||
sessionID,
|
||||
iteration: state.iteration,
|
||||
max: state.max_iterations,
|
||||
})
|
||||
options.loopState.clear()
|
||||
showMaxIterationsToast(ctx, state)
|
||||
return
|
||||
}
|
||||
|
||||
const newState = options.loopState.incrementIteration()
|
||||
if (!newState) {
|
||||
log(`[${HOOK_NAME}] Failed to increment iteration after runtime error`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
showIterationToast(ctx, newState)
|
||||
await sleep(options.idleSettleMs)
|
||||
try {
|
||||
await continueIteration(ctx, newState, {
|
||||
previousSessionID: sessionID,
|
||||
directory: options.directory,
|
||||
apiTimeoutMs: options.apiTimeoutMs,
|
||||
loopState: options.loopState,
|
||||
})
|
||||
runtimeErrorRetriedSessions.set(sessionID, newState.iteration)
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Failed to retry after runtime error`, {
|
||||
sessionID,
|
||||
error: String(err),
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
inFlightSessions.delete(sessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface RalphLoopHook {
|
||||
}
|
||||
|
||||
const DEFAULT_API_TIMEOUT = 5000 as const
|
||||
const DEFAULT_IDLE_SETTLE_MS = 150 as const
|
||||
|
||||
function getMessageCountFromResponse(messagesResponse: unknown): number {
|
||||
if (Array.isArray(messagesResponse)) {
|
||||
@@ -44,6 +45,7 @@ export function createRalphLoopHook(
|
||||
const stateDir = config?.state_dir
|
||||
const getTranscriptPath = options?.getTranscriptPath ?? getDefaultTranscriptPath
|
||||
const apiTimeout = options?.apiTimeout ?? DEFAULT_API_TIMEOUT
|
||||
const idleSettleMs = options?.idleSettleMs ?? DEFAULT_IDLE_SETTLE_MS
|
||||
const checkSessionExists = options?.checkSessionExists
|
||||
const backgroundManager = options?.backgroundManager
|
||||
|
||||
@@ -56,6 +58,7 @@ export function createRalphLoopHook(
|
||||
const event = createRalphLoopEventHandler(ctx, {
|
||||
directory: ctx.directory,
|
||||
apiTimeoutMs: apiTimeout,
|
||||
idleSettleMs,
|
||||
getTranscriptPath,
|
||||
checkSessionExists,
|
||||
backgroundManager,
|
||||
|
||||
@@ -43,49 +43,52 @@ describe("ralph-loop reset strategy race condition", () => {
|
||||
let selectSessionCalls = 0
|
||||
const selectSessionDeferred = createDeferred()
|
||||
|
||||
const hook = createRalphLoopHook({
|
||||
directory: process.cwd(),
|
||||
client: {
|
||||
session: {
|
||||
prompt: async (options: {
|
||||
path: { id: string }
|
||||
body: { parts: Array<{ type: string; text: string }> }
|
||||
}) => {
|
||||
promptCalls.push({
|
||||
sessionID: options.path.id,
|
||||
text: options.body.parts[0].text,
|
||||
})
|
||||
return {}
|
||||
const hook = createRalphLoopHook(
|
||||
{
|
||||
directory: process.cwd(),
|
||||
client: {
|
||||
session: {
|
||||
prompt: async (options: {
|
||||
path: { id: string }
|
||||
body: { parts: Array<{ type: string; text: string }> }
|
||||
}) => {
|
||||
promptCalls.push({
|
||||
sessionID: options.path.id,
|
||||
text: options.body.parts[0].text,
|
||||
})
|
||||
return {}
|
||||
},
|
||||
promptAsync: async (options: {
|
||||
path: { id: string }
|
||||
body: { parts: Array<{ type: string; text: string }> }
|
||||
}) => {
|
||||
promptCalls.push({
|
||||
sessionID: options.path.id,
|
||||
text: options.body.parts[0].text,
|
||||
})
|
||||
return {}
|
||||
},
|
||||
create: async (options: {
|
||||
body: { parentID?: string; title?: string }
|
||||
query?: { directory?: string }
|
||||
}) => {
|
||||
createSessionCalls.push({ parentID: options.body.parentID })
|
||||
return { data: { id: `new-session-${createSessionCalls.length}` } }
|
||||
},
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
promptAsync: async (options: {
|
||||
path: { id: string }
|
||||
body: { parts: Array<{ type: string; text: string }> }
|
||||
}) => {
|
||||
promptCalls.push({
|
||||
sessionID: options.path.id,
|
||||
text: options.body.parts[0].text,
|
||||
})
|
||||
return {}
|
||||
},
|
||||
create: async (options: {
|
||||
body: { parentID?: string; title?: string }
|
||||
query?: { directory?: string }
|
||||
}) => {
|
||||
createSessionCalls.push({ parentID: options.body.parentID })
|
||||
return { data: { id: `new-session-${createSessionCalls.length}` } }
|
||||
},
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
tui: {
|
||||
showToast: async () => ({}),
|
||||
selectSession: async () => {
|
||||
selectSessionCalls += 1
|
||||
await selectSessionDeferred.promise
|
||||
return {}
|
||||
tui: {
|
||||
showToast: async () => ({}),
|
||||
selectSession: async () => {
|
||||
selectSessionCalls += 1
|
||||
await selectSessionDeferred.promise
|
||||
return {}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createRalphLoopHook>[0])
|
||||
} as unknown as Parameters<typeof createRalphLoopHook>[0],
|
||||
{ idleSettleMs: 0 },
|
||||
)
|
||||
|
||||
hook.startLoop("session-old", "Build feature", { strategy: "reset" })
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface RalphLoopOptions {
|
||||
config?: RalphLoopConfig
|
||||
getTranscriptPath?: (sessionId: string) => string
|
||||
apiTimeout?: number
|
||||
idleSettleMs?: number
|
||||
checkSessionExists?: (sessionId: string) => Promise<boolean>
|
||||
backgroundManager?: { getTasksByParentSession: (sessionId: string) => Array<{ status: string }> }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# src/hooks/rules-injector/ — Conditional Rules Injection
|
||||
|
||||
**Generated:** 2026-04-11
|
||||
**Generated:** 2026-05-08
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# src/hooks/runtime-fallback/ — Reactive Provider Error Recovery
|
||||
|
||||
**Generated:** 2026-04-11
|
||||
**Generated:** 2026-05-08
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -132,7 +132,8 @@ export function classifyErrorType(error: unknown): string | undefined {
|
||||
/exhausted\s+your\s+capacity/i.test(message) ||
|
||||
/out\s+of\s+credits?/i.test(message) ||
|
||||
/payment.?required/i.test(message) ||
|
||||
/usage\s+limit/i.test(message)
|
||||
/usage\s+limit/i.test(message) ||
|
||||
/credit\s+balance.*too\s+low/i.test(message)
|
||||
) {
|
||||
return "quota_exceeded"
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@ export function findNextAvailableFallback(
|
||||
): string | undefined {
|
||||
for (let i = state.fallbackIndex + 1; i < fallbackModels.length; i++) {
|
||||
const candidate = fallbackModels[i]
|
||||
if (candidate === state.currentModel) {
|
||||
log(`[${HOOK_NAME}] Skipping fallback model (same as current)`, { model: candidate, index: i })
|
||||
continue
|
||||
}
|
||||
if (!isModelInCooldown(candidate, state, cooldownSeconds)) {
|
||||
return candidate
|
||||
}
|
||||
|
||||
@@ -139,6 +139,16 @@ describe("session-notification input-needed events", () => {
|
||||
expect(detectPlatformSpy).toHaveBeenCalledTimes(1)
|
||||
expect(getDefaultSoundPathSpy).toHaveBeenCalledTimes(1)
|
||||
expect(startBackgroundCheckSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
// when
|
||||
await hook({
|
||||
event: {
|
||||
type: "session.deleted",
|
||||
properties: {
|
||||
info: { id: sessionID },
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -33,6 +33,21 @@ export function getDefaultSoundPath(platform: Platform): string {
|
||||
}
|
||||
}
|
||||
|
||||
type ShellCommand = Promise<unknown> & {
|
||||
quiet?: () => Promise<unknown>
|
||||
nothrow?: () => ShellCommand
|
||||
}
|
||||
|
||||
async function runQuietNothrow(command: ShellCommand): Promise<void> {
|
||||
const safeCommand = typeof command.nothrow === "function" ? command.nothrow() : command
|
||||
if (typeof safeCommand.quiet === "function") {
|
||||
await safeCommand.quiet()
|
||||
return
|
||||
}
|
||||
|
||||
await safeCommand
|
||||
}
|
||||
|
||||
export async function sendSessionNotification(
|
||||
ctx: PluginInput,
|
||||
platform: Platform,
|
||||
@@ -72,14 +87,14 @@ export async function sendSessionNotification(
|
||||
|
||||
const escapedTitle = escapeAppleScriptText(title)
|
||||
const escapedMessage = escapeAppleScriptText(message)
|
||||
await ctx.$`${osascriptPath} -e ${"display notification \"" + escapedMessage + "\" with title \"" + escapedTitle + "\""}`.nothrow().quiet()
|
||||
await runQuietNothrow(ctx.$`${osascriptPath} -e ${"display notification \"" + escapedMessage + "\" with title \"" + escapedTitle + "\""}`)
|
||||
break
|
||||
}
|
||||
case "linux": {
|
||||
const notifySendPath = await getNotifySendPath()
|
||||
if (!notifySendPath) return
|
||||
|
||||
await ctx.$`${notifySendPath} ${title} ${message} 2>/dev/null`.nothrow().quiet()
|
||||
await runQuietNothrow(ctx.$`${notifySendPath} ${title} ${message} 2>/dev/null`)
|
||||
break
|
||||
}
|
||||
case "win32": {
|
||||
@@ -87,7 +102,7 @@ export async function sendSessionNotification(
|
||||
if (!powershellPath) return
|
||||
|
||||
const toastScript = buildWindowsToastScript(title, message)
|
||||
await ctx.$`${powershellPath} -Command ${toastScript}`.nothrow().quiet()
|
||||
await runQuietNothrow(ctx.$`${powershellPath} -Command ${toastScript}`)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -102,17 +117,17 @@ export async function playSessionNotificationSound(
|
||||
case "darwin": {
|
||||
const afplayPath = await getAfplayPath()
|
||||
if (!afplayPath) return
|
||||
ctx.$`${afplayPath} ${soundPath}`.nothrow().quiet()
|
||||
await runQuietNothrow(ctx.$`${afplayPath} ${soundPath}`)
|
||||
break
|
||||
}
|
||||
case "linux": {
|
||||
const paplayPath = await getPaplayPath()
|
||||
if (paplayPath) {
|
||||
ctx.$`${paplayPath} ${soundPath} 2>/dev/null`.nothrow().quiet()
|
||||
await runQuietNothrow(ctx.$`${paplayPath} ${soundPath} 2>/dev/null`)
|
||||
} else {
|
||||
const aplayPath = await getAplayPath()
|
||||
if (aplayPath) {
|
||||
ctx.$`${aplayPath} ${soundPath} 2>/dev/null`.nothrow().quiet()
|
||||
await runQuietNothrow(ctx.$`${aplayPath} ${soundPath} 2>/dev/null`)
|
||||
}
|
||||
}
|
||||
break
|
||||
@@ -121,7 +136,7 @@ export async function playSessionNotificationSound(
|
||||
const powershellPath = await getPowershellPath()
|
||||
if (!powershellPath) return
|
||||
const escaped = escapePowerShellSingleQuotedText(soundPath)
|
||||
ctx.$`${powershellPath} -Command ${"(New-Object Media.SoundPlayer '" + escaped + "').PlaySync()"}`.nothrow().quiet()
|
||||
await runQuietNothrow(ctx.$`${powershellPath} -Command ${"(New-Object Media.SoundPlayer '" + escaped + "').PlaySync()"}`)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,29 +8,85 @@ const originalSetTimeout = globalThis.setTimeout
|
||||
const originalClearTimeout = globalThis.clearTimeout
|
||||
const originalDateNow = Date.now
|
||||
|
||||
type MockPluginInput = Parameters<typeof createSessionNotification>[0]
|
||||
|
||||
type MockShellResult = {
|
||||
stdout: Buffer
|
||||
stderr: Buffer
|
||||
exitCode: number
|
||||
}
|
||||
|
||||
type MockShellChain = Promise<MockShellResult> & {
|
||||
nothrow: () => MockShellChain
|
||||
quiet: () => MockShellChain
|
||||
text: () => Promise<string>
|
||||
}
|
||||
|
||||
function formatShellCommand(cmd: TemplateStringsArray | string, values: readonly unknown[]): string {
|
||||
if (typeof cmd === "string") return cmd
|
||||
return cmd.reduce((acc, part, index) => `${acc}${part}${String(values[index] ?? "")}`, "")
|
||||
}
|
||||
|
||||
function createShellChain(result: MockShellResult, shouldReject = false): MockShellChain {
|
||||
const promise = (shouldReject ? Promise.reject(Object.assign(new Error("command failed"), result)) : Promise.resolve(result)) as MockShellChain
|
||||
const resolvedNothrow = Promise.resolve(result) as MockShellChain
|
||||
|
||||
promise.quiet = () => promise
|
||||
promise.text = async () => ""
|
||||
promise.nothrow = () => resolvedNothrow
|
||||
|
||||
resolvedNothrow.quiet = () => resolvedNothrow
|
||||
resolvedNothrow.text = async () => ""
|
||||
resolvedNothrow.nothrow = () => resolvedNothrow
|
||||
|
||||
return promise
|
||||
}
|
||||
|
||||
function createShellMock(options: {
|
||||
capture?: (commandString: string) => void
|
||||
reject?: (commandString: string, values: readonly unknown[]) => boolean
|
||||
} = {}) {
|
||||
return (cmd: TemplateStringsArray | string, ...values: unknown[]): MockShellChain => {
|
||||
const commandString = formatShellCommand(cmd, values)
|
||||
options.capture?.(commandString)
|
||||
|
||||
return createShellChain(
|
||||
{ stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode: options.reject?.(commandString, values) ? 1 : 0 },
|
||||
options.reject?.(commandString, values) ?? false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function createMockInput(shell: ReturnType<typeof createShellMock>): MockPluginInput {
|
||||
const input = {} as MockPluginInput
|
||||
return Object.assign(input, {
|
||||
$: shell,
|
||||
client: {
|
||||
session: {
|
||||
todo: async () => ({ data: [] }),
|
||||
},
|
||||
},
|
||||
directory: "/tmp/test",
|
||||
project: "/tmp/test",
|
||||
worktree: "/tmp/test",
|
||||
serverUrl: "http://localhost",
|
||||
})
|
||||
}
|
||||
|
||||
describe("session-notification", () => {
|
||||
let notificationCalls: string[]
|
||||
|
||||
function createMockPluginInput() {
|
||||
return {
|
||||
$: async (cmd: TemplateStringsArray | string, ...values: any[]) => {
|
||||
function createMockPluginInput(): MockPluginInput {
|
||||
return createMockInput(
|
||||
createShellMock({
|
||||
capture: (cmdStr) => {
|
||||
// given - track notification commands (osascript, notify-send, powershell)
|
||||
const cmdStr = typeof cmd === "string"
|
||||
? cmd
|
||||
: cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
|
||||
|
||||
if (cmdStr.includes("osascript") || cmdStr.includes("notify-send") || cmdStr.includes("powershell")) {
|
||||
notificationCalls.push(cmdStr)
|
||||
if (cmdStr.includes("osascript") || cmdStr.includes("notify-send") || cmdStr.includes("powershell")) {
|
||||
notificationCalls.push(cmdStr)
|
||||
}
|
||||
}
|
||||
return { stdout: "", stderr: "", exitCode: 0 }
|
||||
},
|
||||
client: {
|
||||
session: {
|
||||
todo: async () => ({ data: [] }),
|
||||
},
|
||||
},
|
||||
directory: "/tmp/test",
|
||||
} as any
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -44,6 +100,7 @@ describe("session-notification", () => {
|
||||
spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript")
|
||||
spyOn(utils, "getNotifySendPath").mockResolvedValue("/usr/bin/notify-send")
|
||||
spyOn(utils, "getPowershellPath").mockResolvedValue("powershell")
|
||||
spyOn(utils, "getCmuxPath").mockResolvedValue(null)
|
||||
spyOn(utils, "getAfplayPath").mockResolvedValue("/usr/bin/afplay")
|
||||
spyOn(utils, "getPaplayPath").mockResolvedValue("/usr/bin/paplay")
|
||||
spyOn(utils, "getAplayPath").mockResolvedValue("/usr/bin/aplay")
|
||||
@@ -389,19 +446,7 @@ describe("session-notification", () => {
|
||||
|
||||
function createSenderMockCtx() {
|
||||
const notifyCalls: string[] = []
|
||||
const mockCtx = {
|
||||
$: (cmd: TemplateStringsArray | string, ...values: any[]) => {
|
||||
const cmdStr = typeof cmd === "string"
|
||||
? cmd
|
||||
: cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
|
||||
notifyCalls.push(cmdStr)
|
||||
const result = { stdout: "", stderr: "", exitCode: 0 }
|
||||
const promise = Promise.resolve(result) as any
|
||||
promise.quiet = () => promise
|
||||
promise.nothrow = () => { const p = Promise.resolve(result) as any; p.quiet = () => p; p.nothrow = () => p; return p }
|
||||
return promise
|
||||
},
|
||||
} as any
|
||||
const mockCtx = createMockInput(createShellMock({ capture: (commandString) => notifyCalls.push(commandString) }))
|
||||
return { mockCtx, notifyCalls }
|
||||
}
|
||||
|
||||
@@ -454,28 +499,12 @@ describe("session-notification", () => {
|
||||
// given - terminal-notifier exists but invocation fails
|
||||
spyOn(sender, "sendSessionNotification").mockRestore()
|
||||
const notifyCalls: string[] = []
|
||||
const mockCtx = {
|
||||
$: (cmd: TemplateStringsArray | string, ...values: unknown[]) => {
|
||||
const cmdStr = typeof cmd === "string"
|
||||
? cmd
|
||||
: cmd.reduce((acc, part, index) => `${acc}${part}${String(values[index] ?? "")}`, "")
|
||||
notifyCalls.push(cmdStr)
|
||||
|
||||
if (cmdStr.includes("terminal-notifier")) {
|
||||
const err = Object.assign(new Error("terminal-notifier failed"), { stdout: "", stderr: "", exitCode: 1 })
|
||||
const rejected = Promise.reject(err) as any
|
||||
rejected.quiet = () => rejected
|
||||
rejected.nothrow = () => { const p = Promise.resolve({ stdout: "", stderr: "", exitCode: 1 }) as any; p.quiet = () => p; p.nothrow = () => p; return p }
|
||||
return rejected
|
||||
}
|
||||
|
||||
const result = { stdout: "", stderr: "", exitCode: 0 }
|
||||
const promise = Promise.resolve(result) as any
|
||||
promise.quiet = () => promise
|
||||
promise.nothrow = () => { const p = Promise.resolve(result) as any; p.quiet = () => p; p.nothrow = () => p; return p }
|
||||
return promise
|
||||
},
|
||||
} as any
|
||||
const mockCtx = createMockInput(
|
||||
createShellMock({
|
||||
capture: (commandString) => notifyCalls.push(commandString),
|
||||
reject: (commandString) => commandString.includes("terminal-notifier"),
|
||||
})
|
||||
)
|
||||
spyOn(utils, "getTerminalNotifierPath").mockResolvedValue("/usr/local/bin/terminal-notifier")
|
||||
spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript")
|
||||
|
||||
@@ -493,27 +522,12 @@ describe("session-notification", () => {
|
||||
// given - shell interpolation rejects array values
|
||||
spyOn(sender, "sendSessionNotification").mockRestore()
|
||||
const notifyCalls: string[] = []
|
||||
const mockCtx = {
|
||||
$: (cmd: TemplateStringsArray | string, ...values: unknown[]) => {
|
||||
if (values.some(Array.isArray)) {
|
||||
const err = Object.assign(new Error("array interpolation unsupported"), { stdout: "", stderr: "", exitCode: 1 })
|
||||
const rejected = Promise.reject(err) as any
|
||||
rejected.quiet = () => rejected
|
||||
rejected.nothrow = () => { const p = Promise.resolve({ stdout: "", stderr: "", exitCode: 1 }) as any; p.quiet = () => p; p.nothrow = () => p; return p }
|
||||
return rejected
|
||||
}
|
||||
|
||||
const commandString = typeof cmd === "string"
|
||||
? cmd
|
||||
: cmd.reduce((acc, part, index) => `${acc}${part}${String(values[index] ?? "")}`, "")
|
||||
notifyCalls.push(commandString)
|
||||
const result = { stdout: "", stderr: "", exitCode: 0 }
|
||||
const promise = Promise.resolve(result) as any
|
||||
promise.quiet = () => promise
|
||||
promise.nothrow = () => { const p = Promise.resolve(result) as any; p.quiet = () => p; p.nothrow = () => p; return p }
|
||||
return promise
|
||||
},
|
||||
} as any
|
||||
const mockCtx = createMockInput(
|
||||
createShellMock({
|
||||
capture: (commandString) => notifyCalls.push(commandString),
|
||||
reject: (_commandString, values) => values.some(Array.isArray),
|
||||
})
|
||||
)
|
||||
spyOn(utils, "getTerminalNotifierPath").mockResolvedValue("/usr/local/bin/terminal-notifier")
|
||||
spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript")
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# src/hooks/session-recovery/ — Auto Session Error Recovery
|
||||
|
||||
**Generated:** 2026-04-11
|
||||
**Generated:** 2026-05-08
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -123,7 +123,9 @@ export function createSessionRecoveryHook(ctx: PluginInput, options?: SessionRec
|
||||
let success = false
|
||||
|
||||
if (errorType === "tool_result_missing") {
|
||||
success = await recoverToolResultMissing(ctx.client, sessionID, failedMsg)
|
||||
const lastUser = findLastUserMessage(msgs ?? [])
|
||||
const resumeConfig = extractResumeConfig(lastUser, sessionID)
|
||||
success = await recoverToolResultMissing(ctx.client, sessionID, failedMsg, resumeConfig)
|
||||
} else if (errorType === "unavailable_tool") {
|
||||
success = await recoverUnavailableTool(ctx.client, sessionID, failedMsg)
|
||||
} else if (errorType === "thinking_block_order") {
|
||||
|
||||
@@ -129,6 +129,63 @@ describe("recoverToolResultMissing", () => {
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("pins agent, model, and variant on promptAsync body when resumeConfig provides them", async () => {
|
||||
// given
|
||||
storedParts = [{
|
||||
type: "tool",
|
||||
id: "prt_stored_pin_call",
|
||||
callID: "toolu_pin",
|
||||
tool: "bash",
|
||||
state: { input: {} },
|
||||
}]
|
||||
const { client, promptAsync } = createMockClient()
|
||||
const resumeConfig = {
|
||||
sessionID: "ses_pin",
|
||||
agent: "Hephaestus",
|
||||
model: { providerID: "openai", modelID: "gpt-5.3-codex", variant: "max" },
|
||||
}
|
||||
|
||||
// when
|
||||
const result = await recoverToolResultMissing(client, "ses_pin", failedAssistantMsg, resumeConfig)
|
||||
|
||||
// then
|
||||
expect(result).toBe(true)
|
||||
expect(promptAsync).toHaveBeenCalledTimes(1)
|
||||
const call = promptAsync.mock.calls[0]?.[0] as {
|
||||
body: {
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
variant?: string
|
||||
parts: unknown[]
|
||||
}
|
||||
}
|
||||
expect(call.body.agent).toBe("Hephaestus")
|
||||
expect(call.body.model).toEqual({ providerID: "openai", modelID: "gpt-5.3-codex" })
|
||||
expect(call.body.variant).toBe("max")
|
||||
})
|
||||
|
||||
it("leaves body unchanged when no resumeConfig is provided", async () => {
|
||||
// given
|
||||
storedParts = [{
|
||||
type: "tool",
|
||||
id: "prt_stored_nopin_call",
|
||||
callID: "toolu_nopin",
|
||||
tool: "bash",
|
||||
state: { input: {} },
|
||||
}]
|
||||
const { client, promptAsync } = createMockClient()
|
||||
|
||||
// when
|
||||
const result = await recoverToolResultMissing(client, "ses_nopin", failedAssistantMsg)
|
||||
|
||||
// then
|
||||
expect(result).toBe(true)
|
||||
const call = promptAsync.mock.calls[0]?.[0] as { body: Record<string, unknown> }
|
||||
expect(call.body).not.toHaveProperty("agent")
|
||||
expect(call.body).not.toHaveProperty("model")
|
||||
expect(call.body).not.toHaveProperty("variant")
|
||||
})
|
||||
})
|
||||
|
||||
export {}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import type { MessageData } from "./types"
|
||||
import type { MessageData, ResumeConfig } from "./types"
|
||||
import { readParts } from "./storage"
|
||||
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
|
||||
import { normalizeSDKResponse } from "../../shared"
|
||||
@@ -70,7 +70,8 @@ async function readPartsFromSDKFallback(
|
||||
export async function recoverToolResultMissing(
|
||||
client: Client,
|
||||
sessionID: string,
|
||||
failedAssistantMsg: MessageData
|
||||
failedAssistantMsg: MessageData,
|
||||
resumeConfig?: ResumeConfig
|
||||
): Promise<boolean> {
|
||||
let parts = failedAssistantMsg.parts || []
|
||||
if (parts.length === 0 && failedAssistantMsg.info?.id) {
|
||||
@@ -93,9 +94,20 @@ export async function recoverToolResultMissing(
|
||||
content: "Operation cancelled by user (ESC pressed)",
|
||||
}))
|
||||
|
||||
const launchAgent = resumeConfig?.agent
|
||||
const launchModel = resumeConfig?.model
|
||||
? { providerID: resumeConfig.model.providerID, modelID: resumeConfig.model.modelID }
|
||||
: undefined
|
||||
const launchVariant = resumeConfig?.model?.variant
|
||||
|
||||
const promptInput = {
|
||||
path: { id: sessionID },
|
||||
body: { parts: toolResultParts },
|
||||
body: {
|
||||
parts: toolResultParts,
|
||||
...(launchAgent ? { agent: launchAgent } : {}),
|
||||
...(launchModel ? { model: launchModel } : {}),
|
||||
...(launchVariant ? { variant: launchVariant } : {}),
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export const DEFAULT_SESSION_IDLE_SETTLE_MS = 150
|
||||
|
||||
export function settleAfterSessionIdle(ms = DEFAULT_SESSION_IDLE_SETTLE_MS): Promise<void> {
|
||||
return ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve()
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getPlanName,
|
||||
getPlanProgress,
|
||||
readBoulderState,
|
||||
resolveBoulderPlanPath,
|
||||
writeBoulderState,
|
||||
} from "../../features/boulder-state"
|
||||
import { log } from "../../shared/logger"
|
||||
@@ -150,7 +151,8 @@ function buildExistingSessionContext(params: {
|
||||
directory: string
|
||||
}): string {
|
||||
const { existingState, sessionId, activeAgent, worktreePath, worktreeBlock, directory } = params
|
||||
const progress = getPlanProgress(existingState.active_plan)
|
||||
const planPath = resolveBoulderPlanPath(directory, existingState)
|
||||
const progress = getPlanProgress(planPath)
|
||||
if (progress.isComplete) {
|
||||
return `
|
||||
## Previous Work Complete
|
||||
@@ -186,7 +188,7 @@ Looking for new plans...`
|
||||
|
||||
**Status**: RESUMING existing work
|
||||
**Plan**: ${existingState.plan_name}
|
||||
**Path**: ${existingState.active_plan}
|
||||
**Path**: ${planPath}
|
||||
**Progress**: ${progress.completed}/${progress.total} tasks completed
|
||||
**Sessions**: ${existingState.session_ids.length + 1} (current session appended)
|
||||
**Started**: ${existingState.started_at}
|
||||
@@ -197,11 +199,16 @@ Read the plan file and continue from the first unchecked task.`
|
||||
}
|
||||
|
||||
function shouldDiscoverPlans(
|
||||
directory: string,
|
||||
existingState: ReturnType<typeof readBoulderState>,
|
||||
explicitPlanName: string | null,
|
||||
): boolean {
|
||||
return (!existingState && !explicitPlanName)
|
||||
|| (existingState !== null && !explicitPlanName && getPlanProgress(existingState.active_plan).isComplete)
|
||||
|| (
|
||||
existingState !== null
|
||||
&& !explicitPlanName
|
||||
&& getPlanProgress(resolveBoulderPlanPath(directory, existingState)).isComplete
|
||||
)
|
||||
}
|
||||
|
||||
function buildPlanDiscoveryContext(params: {
|
||||
@@ -303,7 +310,7 @@ export function buildStartWorkContextInfo(params: {
|
||||
})
|
||||
}
|
||||
|
||||
if (shouldDiscoverPlans(existingState, explicitPlanName)) {
|
||||
if (shouldDiscoverPlans(ctx.directory, existingState, explicitPlanName)) {
|
||||
return buildPlanDiscoveryContext({
|
||||
contextInfo,
|
||||
sessionId,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { dirname, join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { createStartWorkHook } from "./index"
|
||||
@@ -1013,5 +1013,39 @@ You are starting a Sisyphus work session.
|
||||
expect(output.parts[0].text).toContain("subagent")
|
||||
expect(output.parts[0].text).not.toContain("Worktree Setup Required")
|
||||
})
|
||||
|
||||
test("should show worktree plan progress and path when the mirrored plan exists", async () => {
|
||||
// given
|
||||
const mainPlanPath = join(testDir, ".sisyphus", "plans", "resume-worktree-plan.md")
|
||||
const worktreeDir = join(testDir, "..", `resume-worktree-${randomUUID()}`)
|
||||
const worktreePlanPath = join(worktreeDir, ".sisyphus", "plans", "resume-worktree-plan.md")
|
||||
mkdirSync(dirname(mainPlanPath), { recursive: true })
|
||||
mkdirSync(dirname(worktreePlanPath), { recursive: true })
|
||||
writeFileSync(mainPlanPath, "# Plan\n- [ ] Main repo task\n")
|
||||
writeFileSync(worktreePlanPath, "# Plan\n- [x] Worktree task 1\n- [ ] Worktree task 2\n")
|
||||
writeBoulderState(testDir, {
|
||||
active_plan: mainPlanPath,
|
||||
started_at: "2026-01-01T00:00:00Z",
|
||||
session_ids: ["old-session"],
|
||||
plan_name: "resume-worktree-plan",
|
||||
worktree_path: worktreeDir,
|
||||
})
|
||||
|
||||
const hook = createStartWorkHook(createMockPluginInput())
|
||||
const output = {
|
||||
parts: [{ type: "text", text: createStartWorkPrompt() }],
|
||||
}
|
||||
|
||||
try {
|
||||
// when
|
||||
await hook["chat.message"]({ sessionID: "session-worktree-progress" }, output)
|
||||
|
||||
// then
|
||||
expect(output.parts[0].text).toContain(worktreePlanPath)
|
||||
expect(output.parts[0].text).toContain("1/2 tasks completed")
|
||||
} finally {
|
||||
rmSync(worktreeDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -46,8 +46,8 @@ describe("stop-continuation-guard", () => {
|
||||
id,
|
||||
status,
|
||||
description: `${id} description`,
|
||||
parentSessionID: "parent-session",
|
||||
parentMessageID: "parent-message",
|
||||
parentSessionId: "parent-session",
|
||||
parentMessageId: "parent-message",
|
||||
prompt: "prompt",
|
||||
agent: "sisyphus-junior",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
import { TeamModeConfigSchema } from "../../config/schema/team-mode"
|
||||
import {
|
||||
clearTeamSessionRegistry,
|
||||
registerTeamSession,
|
||||
} from "../../features/team-mode/team-session-registry"
|
||||
import { sendMessage } from "../../features/team-mode/team-mailbox/send"
|
||||
import type { RuntimeState } from "../../features/team-mode/types"
|
||||
import { saveRuntimeState } from "../../features/team-mode/team-state-store/store"
|
||||
import { createTeamMailboxInjector } from "./hook"
|
||||
|
||||
function createRuntimeState(sessionID: string, teamRunId = randomUUID()): RuntimeState {
|
||||
return {
|
||||
version: 1,
|
||||
teamRunId,
|
||||
teamName: "team-alpha",
|
||||
specSource: "project",
|
||||
createdAt: 1,
|
||||
status: "active",
|
||||
leadSessionId: "lead-session",
|
||||
members: [
|
||||
{
|
||||
name: "member-a",
|
||||
sessionId: sessionID,
|
||||
agentType: "general-purpose",
|
||||
status: "running",
|
||||
lastInjectedTurnMarker: undefined,
|
||||
pendingInjectedMessageIds: [],
|
||||
},
|
||||
],
|
||||
shutdownRequests: [],
|
||||
bounds: {
|
||||
maxMembers: 8,
|
||||
maxParallelMembers: 4,
|
||||
maxMessagesPerRun: 10000,
|
||||
maxWallClockMinutes: 120,
|
||||
maxMemberTurns: 500,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function createTemporaryBaseDir(): Promise<string> {
|
||||
return await mkdtemp(path.join(tmpdir(), "team-mailbox-injector-"))
|
||||
}
|
||||
|
||||
async function seedRuntimeState(baseDir: string, runtimeState: RuntimeState): Promise<void> {
|
||||
const config = TeamModeConfigSchema.parse({ base_dir: baseDir, enabled: true })
|
||||
await mkdir(path.join(baseDir, "runtime", runtimeState.teamRunId), { recursive: true })
|
||||
await saveRuntimeState(runtimeState, config)
|
||||
}
|
||||
|
||||
function createHook(baseDir: string) {
|
||||
return createTeamMailboxInjector(
|
||||
{},
|
||||
TeamModeConfigSchema.parse({ enabled: true, base_dir: baseDir }),
|
||||
)
|
||||
}
|
||||
|
||||
function createOutput(sessionID: string): {
|
||||
messages: Array<{
|
||||
info: { role: string; sessionID: string }
|
||||
parts: Array<{ type: string; text?: string; synthetic?: boolean }>
|
||||
}>
|
||||
} {
|
||||
return {
|
||||
messages: [
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
sessionID,
|
||||
},
|
||||
parts: [{ type: "text", text: "original message" }],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe("createTeamMailboxInjector", () => {
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
clearTeamSessionRegistry()
|
||||
await Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => rm(directoryPath, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
it("returns the input unchanged for a non-member session", async () => {
|
||||
// given
|
||||
const baseDir = await createTemporaryBaseDir()
|
||||
temporaryDirectories.push(baseDir)
|
||||
const hook = createHook(baseDir)
|
||||
const output = createOutput("session-non-member")
|
||||
const originalMessages = structuredClone(output.messages)
|
||||
|
||||
// when
|
||||
await hook["experimental.chat.messages.transform"]?.(
|
||||
{ sessionID: "session-non-member" },
|
||||
output,
|
||||
)
|
||||
|
||||
// then
|
||||
expect(output.messages).toEqual(originalMessages)
|
||||
})
|
||||
|
||||
it("prepends an envelope as a user-role message for a member session", async () => {
|
||||
// given
|
||||
const baseDir = await createTemporaryBaseDir()
|
||||
temporaryDirectories.push(baseDir)
|
||||
const hook = createHook(baseDir)
|
||||
const runtimeState = createRuntimeState("session-member")
|
||||
await seedRuntimeState(baseDir, runtimeState)
|
||||
await sendMessage({
|
||||
version: 1,
|
||||
messageId: randomUUID(),
|
||||
from: "lead",
|
||||
to: "member-a",
|
||||
kind: "message",
|
||||
body: "hello",
|
||||
timestamp: 1,
|
||||
}, runtimeState.teamRunId, TeamModeConfigSchema.parse({ base_dir: baseDir, enabled: true }), { isLead: true, activeMembers: ["lead", "member-a"] })
|
||||
const output = createOutput("session-member")
|
||||
|
||||
// when
|
||||
await hook["experimental.chat.messages.transform"]?.(
|
||||
{ sessionID: "session-member" },
|
||||
output,
|
||||
)
|
||||
|
||||
// then
|
||||
expect(output.messages).toHaveLength(2)
|
||||
expect(output.messages[0]).toEqual({
|
||||
info: {
|
||||
role: "user",
|
||||
sessionID: "session-member",
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: expect.stringContaining('<peer_message from="lead"'),
|
||||
synthetic: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("does not inject twice for the same turn marker", async () => {
|
||||
// given
|
||||
const baseDir = await createTemporaryBaseDir()
|
||||
temporaryDirectories.push(baseDir)
|
||||
const hook = createHook(baseDir)
|
||||
const runtimeState = createRuntimeState("session-member")
|
||||
await seedRuntimeState(baseDir, runtimeState)
|
||||
await sendMessage({
|
||||
version: 1,
|
||||
messageId: randomUUID(),
|
||||
from: "lead",
|
||||
to: "member-a",
|
||||
kind: "message",
|
||||
body: "hello",
|
||||
timestamp: 1,
|
||||
}, runtimeState.teamRunId, TeamModeConfigSchema.parse({ base_dir: baseDir, enabled: true }), { isLead: true, activeMembers: ["lead", "member-a"] })
|
||||
const firstOutput = createOutput("session-member")
|
||||
const secondOutput = createOutput("session-member")
|
||||
const originalSecondMessages = structuredClone(secondOutput.messages)
|
||||
|
||||
// when
|
||||
await hook["experimental.chat.messages.transform"]?.(
|
||||
{ sessionID: "session-member" },
|
||||
firstOutput,
|
||||
)
|
||||
await hook["experimental.chat.messages.transform"]?.(
|
||||
{ sessionID: "session-member" },
|
||||
secondOutput,
|
||||
)
|
||||
|
||||
// then
|
||||
expect(firstOutput.messages).toHaveLength(2)
|
||||
expect(secondOutput.messages).toEqual(originalSecondMessages)
|
||||
})
|
||||
|
||||
it("injects mailbox messages during the spawn race when the registry has the fresh member session but disk state is stale", async () => {
|
||||
// given
|
||||
const baseDir = await createTemporaryBaseDir()
|
||||
temporaryDirectories.push(baseDir)
|
||||
const hook = createHook(baseDir)
|
||||
const teamRunId = randomUUID()
|
||||
const staleRuntimeState: RuntimeState = {
|
||||
...createRuntimeState("stale-session", teamRunId),
|
||||
members: [
|
||||
{
|
||||
name: "member-a",
|
||||
agentType: "general-purpose",
|
||||
status: "running",
|
||||
lastInjectedTurnMarker: undefined,
|
||||
pendingInjectedMessageIds: [],
|
||||
},
|
||||
],
|
||||
}
|
||||
await seedRuntimeState(baseDir, staleRuntimeState)
|
||||
await sendMessage({
|
||||
version: 1,
|
||||
messageId: randomUUID(),
|
||||
from: "lead",
|
||||
to: "member-a",
|
||||
kind: "message",
|
||||
body: "fresh registry hello",
|
||||
timestamp: 1,
|
||||
}, teamRunId, TeamModeConfigSchema.parse({ base_dir: baseDir, enabled: true }), { isLead: true, activeMembers: ["lead", "member-a"] })
|
||||
registerTeamSession("session-member", {
|
||||
teamRunId,
|
||||
memberName: "member-a",
|
||||
role: "member",
|
||||
})
|
||||
const output = createOutput("session-member")
|
||||
|
||||
// when
|
||||
await hook["experimental.chat.messages.transform"]?.(
|
||||
{ sessionID: "session-member" },
|
||||
output,
|
||||
)
|
||||
|
||||
// then
|
||||
expect(output.messages).toHaveLength(2)
|
||||
expect(output.messages[0]?.parts[0]?.text).toContain("fresh registry hello")
|
||||
})
|
||||
|
||||
it("falls back to disk lookup when the registry points the session at the wrong teamRunId", async () => {
|
||||
// given
|
||||
const baseDir = await createTemporaryBaseDir()
|
||||
temporaryDirectories.push(baseDir)
|
||||
const hook = createHook(baseDir)
|
||||
const correctTeamRunId = randomUUID()
|
||||
const wrongTeamRunId = randomUUID()
|
||||
await seedRuntimeState(baseDir, createRuntimeState("session-member", correctTeamRunId))
|
||||
await seedRuntimeState(baseDir, createRuntimeState("other-session", wrongTeamRunId))
|
||||
await sendMessage({
|
||||
version: 1,
|
||||
messageId: randomUUID(),
|
||||
from: "lead",
|
||||
to: "member-a",
|
||||
kind: "message",
|
||||
body: "message for the correct team",
|
||||
timestamp: 1,
|
||||
}, correctTeamRunId, TeamModeConfigSchema.parse({ base_dir: baseDir, enabled: true }), { isLead: true, activeMembers: ["lead", "member-a"] })
|
||||
await sendMessage({
|
||||
version: 1,
|
||||
messageId: randomUUID(),
|
||||
from: "lead",
|
||||
to: "member-a",
|
||||
kind: "message",
|
||||
body: "message for the wrong team",
|
||||
timestamp: 2,
|
||||
}, wrongTeamRunId, TeamModeConfigSchema.parse({ base_dir: baseDir, enabled: true }), { isLead: true, activeMembers: ["lead", "member-a"] })
|
||||
registerTeamSession("session-member", {
|
||||
teamRunId: wrongTeamRunId,
|
||||
memberName: "member-a",
|
||||
role: "member",
|
||||
})
|
||||
const output = createOutput("session-member")
|
||||
|
||||
// when
|
||||
await hook["experimental.chat.messages.transform"]?.(
|
||||
{ sessionID: "session-member" },
|
||||
output,
|
||||
)
|
||||
|
||||
// then
|
||||
expect(output.messages).toHaveLength(2)
|
||||
const injectedText = output.messages[0]?.parts[0]?.text ?? ""
|
||||
expect(injectedText).toContain("message for the correct team")
|
||||
expect(injectedText).not.toContain("message for the wrong team")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { TeamModeConfig } from "../../config/schema/team-mode"
|
||||
import { findResolvedMemberSession } from "../../features/team-mode/member-session-resolution"
|
||||
import type { PluginContext } from "../../plugin/types"
|
||||
import type { ExecutorContext } from "../../tools/delegate-task/executor-types"
|
||||
|
||||
import { pollAndBuildInjection } from "../../features/team-mode/team-mailbox/poll"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
type HookContext = ExecutorContext | PluginContext | Record<string, never>
|
||||
|
||||
type TransformPart = {
|
||||
type: string
|
||||
text?: string
|
||||
synthetic?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type TransformMessageInfo = {
|
||||
role: string
|
||||
sessionID?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type MessageWithParts = {
|
||||
info: TransformMessageInfo
|
||||
parts: TransformPart[]
|
||||
}
|
||||
|
||||
type TeamMailboxInjectorInput = {
|
||||
sessionID?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type TeamMailboxInjectorOutput = {
|
||||
messages: MessageWithParts[]
|
||||
}
|
||||
|
||||
export type TeamMailboxInjectorHook = {
|
||||
"experimental.chat.messages.transform"?: (
|
||||
input: TeamMailboxInjectorInput,
|
||||
output: TeamMailboxInjectorOutput,
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
function resolveSessionID(
|
||||
input: TeamMailboxInjectorInput,
|
||||
messages: MessageWithParts[],
|
||||
): string | undefined {
|
||||
if (typeof input.sessionID === "string" && input.sessionID.length > 0) {
|
||||
return input.sessionID
|
||||
}
|
||||
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const sessionID = messages[index]?.info.sessionID
|
||||
if (typeof sessionID === "string" && sessionID.length > 0) {
|
||||
return sessionID
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function buildTurnMarker(sessionID: string, messages: MessageWithParts[]): string {
|
||||
return `${sessionID}#${messages.length}`
|
||||
}
|
||||
|
||||
function findLastUserMessageIndex(messages: MessageWithParts[]): number {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
if (messages[index]?.info.role === "user") {
|
||||
return index
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
function createInjectedMessage(
|
||||
sessionID: string,
|
||||
content: string,
|
||||
): MessageWithParts {
|
||||
return {
|
||||
info: {
|
||||
role: "user",
|
||||
sessionID,
|
||||
},
|
||||
parts: [{ type: "text", text: content, synthetic: true }],
|
||||
}
|
||||
}
|
||||
|
||||
export function createTeamMailboxInjector(
|
||||
_ctx: HookContext,
|
||||
config: TeamModeConfig,
|
||||
): TeamMailboxInjectorHook {
|
||||
return {
|
||||
"experimental.chat.messages.transform": async (
|
||||
input,
|
||||
output,
|
||||
): Promise<void> => {
|
||||
if (!config.enabled || output.messages.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const sessionID = resolveSessionID(input, output.messages)
|
||||
if (sessionID === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const runtimeMember = await findResolvedMemberSession(sessionID, config, "team mailbox injector")
|
||||
if (runtimeMember === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const turnMarker = buildTurnMarker(sessionID, output.messages)
|
||||
const result = await pollAndBuildInjection(
|
||||
sessionID,
|
||||
runtimeMember.memberName,
|
||||
runtimeMember.teamRunId,
|
||||
config,
|
||||
turnMarker,
|
||||
)
|
||||
|
||||
if (!result.injected || result.content === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const lastUserMessageIndex = findLastUserMessageIndex(output.messages)
|
||||
const injectedMessage = createInjectedMessage(sessionID, result.content)
|
||||
|
||||
if (lastUserMessageIndex === -1) {
|
||||
output.messages.unshift(injectedMessage)
|
||||
return
|
||||
}
|
||||
|
||||
output.messages.splice(lastUserMessageIndex, 0, injectedMessage)
|
||||
} catch (error) {
|
||||
log("[team-mailbox-injector] Failed to inject team mailbox messages", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
sessionID,
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { createTeamMailboxInjector } from "./hook"
|
||||
export type { TeamMailboxInjectorHook } from "./hook"
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
|
||||
import { TeamModeConfigSchema } from "../../config/schema/team-mode"
|
||||
import { createTeamModeStatusInjector } from "./hook"
|
||||
|
||||
function createOutput(sessionID: string): {
|
||||
messages: Array<{
|
||||
info: { role: string; sessionID: string }
|
||||
parts: Array<{ type: string; text?: string; synthetic?: boolean }>
|
||||
}>
|
||||
} {
|
||||
return {
|
||||
messages: [
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
sessionID,
|
||||
},
|
||||
parts: [{ type: "text", text: "original message" }],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe("createTeamModeStatusInjector", () => {
|
||||
it("injects a one-time team mode enabled message before the latest user message", async () => {
|
||||
// given
|
||||
const hook = createTeamModeStatusInjector(TeamModeConfigSchema.parse({ enabled: true }))
|
||||
const output = createOutput("session-team-mode")
|
||||
|
||||
// when
|
||||
await hook["experimental.chat.messages.transform"]?.(
|
||||
{ sessionID: "session-team-mode" },
|
||||
output,
|
||||
)
|
||||
|
||||
// then
|
||||
expect(output.messages).toHaveLength(2)
|
||||
expect(output.messages[0]).toEqual({
|
||||
info: {
|
||||
role: "user",
|
||||
sessionID: "session-team-mode",
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: expect.stringContaining("Team mode is ENABLED for this session."),
|
||||
synthetic: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(output.messages[1]?.parts[0]?.text).toBe("original message")
|
||||
})
|
||||
|
||||
it("does not inject again when the team mode status was already added", async () => {
|
||||
// given
|
||||
const hook = createTeamModeStatusInjector(TeamModeConfigSchema.parse({ enabled: true }))
|
||||
const firstOutput = createOutput("session-team-mode")
|
||||
const secondOutput = createOutput("session-team-mode")
|
||||
|
||||
// when
|
||||
await hook["experimental.chat.messages.transform"]?.(
|
||||
{ sessionID: "session-team-mode" },
|
||||
firstOutput,
|
||||
)
|
||||
secondOutput.messages = structuredClone(firstOutput.messages)
|
||||
await hook["experimental.chat.messages.transform"]?.(
|
||||
{ sessionID: "session-team-mode" },
|
||||
secondOutput,
|
||||
)
|
||||
|
||||
// then
|
||||
expect(firstOutput.messages).toHaveLength(2)
|
||||
expect(secondOutput.messages).toHaveLength(2)
|
||||
expect(
|
||||
secondOutput.messages.filter((message) =>
|
||||
message.parts.some((part) => part.text?.includes("<team_mode_status enabled=\"true\">")),
|
||||
),
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("does nothing when team mode is disabled", async () => {
|
||||
// given
|
||||
const hook = createTeamModeStatusInjector(TeamModeConfigSchema.parse({ enabled: false }))
|
||||
const output = createOutput("session-team-mode")
|
||||
|
||||
// when
|
||||
await hook["experimental.chat.messages.transform"]?.(
|
||||
{ sessionID: "session-team-mode" },
|
||||
output,
|
||||
)
|
||||
|
||||
// then
|
||||
expect(output.messages).toHaveLength(1)
|
||||
expect(output.messages[0]?.parts[0]?.text).toBe("original message")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { TeamModeConfig } from "../../config/schema/team-mode"
|
||||
|
||||
type TransformPart = {
|
||||
type: string
|
||||
text?: string
|
||||
synthetic?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type TransformMessageInfo = {
|
||||
role: string
|
||||
sessionID?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type MessageWithParts = {
|
||||
info: TransformMessageInfo
|
||||
parts: TransformPart[]
|
||||
}
|
||||
|
||||
type TeamModeStatusInjectorInput = {
|
||||
sessionID?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type TeamModeStatusInjectorOutput = {
|
||||
messages: MessageWithParts[]
|
||||
}
|
||||
|
||||
export type TeamModeStatusInjectorHook = {
|
||||
"experimental.chat.messages.transform"?: (
|
||||
input: TeamModeStatusInjectorInput,
|
||||
output: TeamModeStatusInjectorOutput,
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
const TEAM_MODE_STATUS_MARKER = "<team_mode_status enabled=\"true\">"
|
||||
|
||||
function resolveSessionID(
|
||||
input: TeamModeStatusInjectorInput,
|
||||
messages: MessageWithParts[],
|
||||
): string | undefined {
|
||||
if (typeof input.sessionID === "string" && input.sessionID.length > 0) {
|
||||
return input.sessionID
|
||||
}
|
||||
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const sessionID = messages[index]?.info.sessionID
|
||||
if (typeof sessionID === "string" && sessionID.length > 0) {
|
||||
return sessionID
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function findLastUserMessageIndex(messages: MessageWithParts[]): number {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
if (messages[index]?.info.role === "user") {
|
||||
return index
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
function hasInjectedTeamModeStatus(messages: MessageWithParts[]): boolean {
|
||||
return messages.some((message) =>
|
||||
message.parts.some(
|
||||
(part) => part.synthetic === true && part.type === "text" && part.text?.includes(TEAM_MODE_STATUS_MARKER),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function buildTeamModeStatusContent(): string {
|
||||
return `${TEAM_MODE_STATUS_MARKER}
|
||||
Team mode is ENABLED for this session.
|
||||
If the team_* tools are present, that is authoritative proof that team mode is active.
|
||||
Do not inspect ~/.config/opencode or project config files to verify team mode.
|
||||
If you need usage guidance, load the team-mode skill. Otherwise use the team_* tools directly.
|
||||
</team_mode_status>`
|
||||
}
|
||||
|
||||
function createInjectedMessage(sessionID: string): MessageWithParts {
|
||||
return {
|
||||
info: {
|
||||
role: "user",
|
||||
sessionID,
|
||||
},
|
||||
parts: [{ type: "text", text: buildTeamModeStatusContent(), synthetic: true }],
|
||||
}
|
||||
}
|
||||
|
||||
export function createTeamModeStatusInjector(
|
||||
config: TeamModeConfig,
|
||||
): TeamModeStatusInjectorHook {
|
||||
return {
|
||||
"experimental.chat.messages.transform": async (
|
||||
input,
|
||||
output,
|
||||
): Promise<void> => {
|
||||
if (!config.enabled || output.messages.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (hasInjectedTeamModeStatus(output.messages)) {
|
||||
return
|
||||
}
|
||||
|
||||
const sessionID = resolveSessionID(input, output.messages)
|
||||
if (sessionID === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const lastUserMessageIndex = findLastUserMessageIndex(output.messages)
|
||||
const injectedMessage = createInjectedMessage(sessionID)
|
||||
|
||||
if (lastUserMessageIndex === -1) {
|
||||
output.messages.unshift(injectedMessage)
|
||||
return
|
||||
}
|
||||
|
||||
output.messages.splice(lastUserMessageIndex, 0, injectedMessage)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { createTeamModeStatusInjector } from "./hook"
|
||||
@@ -0,0 +1,494 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { mkdtemp, mkdir, readdir, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
import { TeamModeConfigSchema } from "../../config/schema/team-mode"
|
||||
import type { TeamModeConfig } from "../../config/schema/team-mode"
|
||||
import * as ackModule from "../../features/team-mode/team-mailbox/ack"
|
||||
import { sendMessage } from "../../features/team-mode/team-mailbox/send"
|
||||
import {
|
||||
clearTeamSessionRegistry,
|
||||
registerTeamSession,
|
||||
} from "../../features/team-mode/team-session-registry"
|
||||
import { getInboxDir, resolveBaseDir } from "../../features/team-mode/team-registry/paths"
|
||||
import { loadRuntimeState, saveRuntimeState } from "../../features/team-mode/team-state-store/store"
|
||||
import type { RuntimeState } from "../../features/team-mode/types"
|
||||
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||
import {
|
||||
clearAllSessionPromptParams,
|
||||
getSessionPromptParams,
|
||||
} from "../../shared/session-prompt-params-state"
|
||||
import { createTeamIdleWakeHint } from "./team-idle-wake-hint"
|
||||
|
||||
type WakeHintPromptInput = {
|
||||
path: { id: string }
|
||||
body: {
|
||||
parts: Array<{ type: "text"; text: string }>
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
variant?: string
|
||||
temperature?: number
|
||||
topP?: number
|
||||
maxOutputTokens?: number
|
||||
options?: Record<string, unknown>
|
||||
}
|
||||
query: { directory: string }
|
||||
}
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
async function createTemporaryBaseDir(): Promise<string> {
|
||||
const baseDir = await mkdtemp(path.join(tmpdir(), "team-idle-wake-hint-"))
|
||||
temporaryDirectories.push(baseDir)
|
||||
return baseDir
|
||||
}
|
||||
|
||||
function createConfig(baseDir: string): TeamModeConfig {
|
||||
return TeamModeConfigSchema.parse({ base_dir: baseDir, enabled: true })
|
||||
}
|
||||
|
||||
function createRuntimeState(teamRunId: string, pendingInjectedMessageIds: string[] = []): RuntimeState {
|
||||
return {
|
||||
version: 1,
|
||||
teamRunId,
|
||||
teamName: "team-alpha",
|
||||
specSource: "project",
|
||||
createdAt: 1,
|
||||
status: "active",
|
||||
leadSessionId: "lead-session",
|
||||
members: [
|
||||
{
|
||||
name: "worker",
|
||||
sessionId: "member-session",
|
||||
agentType: "general-purpose",
|
||||
status: "idle",
|
||||
pendingInjectedMessageIds,
|
||||
},
|
||||
],
|
||||
shutdownRequests: [],
|
||||
bounds: {
|
||||
maxMembers: 8,
|
||||
maxParallelMembers: 4,
|
||||
maxMessagesPerRun: 10000,
|
||||
maxWallClockMinutes: 120,
|
||||
maxMemberTurns: 500,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function seedRuntimeState(runtimeState: RuntimeState, config: TeamModeConfig): Promise<void> {
|
||||
await mkdir(path.join(config.base_dir ?? "", "runtime", runtimeState.teamRunId), { recursive: true })
|
||||
await saveRuntimeState(runtimeState, config)
|
||||
}
|
||||
|
||||
async function seedUnreadMessage(
|
||||
teamRunId: string,
|
||||
config: TeamModeConfig,
|
||||
messageId: string,
|
||||
body: string,
|
||||
timestamp: number,
|
||||
): Promise<void> {
|
||||
await sendMessage({
|
||||
version: 1,
|
||||
messageId,
|
||||
from: "lead",
|
||||
to: "worker",
|
||||
kind: "message",
|
||||
body,
|
||||
timestamp,
|
||||
}, teamRunId, config, { isLead: true, activeMembers: ["worker"] })
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
clearTeamSessionRegistry()
|
||||
SessionCategoryRegistry.clear()
|
||||
clearAllSessionPromptParams()
|
||||
await Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => {
|
||||
await rm(directoryPath, { recursive: true, force: true })
|
||||
}))
|
||||
})
|
||||
|
||||
describe("createTeamIdleWakeHint", () => {
|
||||
test("settles idle before sending the wake hint", async () => {
|
||||
// given
|
||||
const baseDir = await createTemporaryBaseDir()
|
||||
const config = createConfig(baseDir)
|
||||
const teamRunId = randomUUID()
|
||||
await seedRuntimeState(createRuntimeState(teamRunId), config)
|
||||
await seedUnreadMessage(teamRunId, config, randomUUID(), "first message body", 100)
|
||||
|
||||
const promptAsyncSpy = mock(async (_input: WakeHintPromptInput) => ({}))
|
||||
const handler = createTeamIdleWakeHint({
|
||||
directory: "/tmp/project",
|
||||
client: { session: { promptAsync: promptAsyncSpy } },
|
||||
}, config, { idleSettleMs: 50 })
|
||||
|
||||
// when
|
||||
const startedAt = Date.now()
|
||||
const eventPromise = handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "member-session" },
|
||||
},
|
||||
})
|
||||
await Promise.resolve()
|
||||
|
||||
// then
|
||||
expect(promptAsyncSpy).not.toHaveBeenCalled()
|
||||
|
||||
await eventPromise
|
||||
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(45)
|
||||
expect(promptAsyncSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test("sends a trigger-only wake hint when new unread mail exists", async () => {
|
||||
// given
|
||||
const baseDir = await createTemporaryBaseDir()
|
||||
const config = createConfig(baseDir)
|
||||
const teamRunId = randomUUID()
|
||||
await seedRuntimeState(createRuntimeState(teamRunId), config)
|
||||
await seedUnreadMessage(teamRunId, config, randomUUID(), "first message body", 100)
|
||||
await seedUnreadMessage(teamRunId, config, randomUUID(), "second message body", 200)
|
||||
|
||||
const promptInputs: Array<WakeHintPromptInput> = []
|
||||
const promptAsyncSpy = mock(async (input: WakeHintPromptInput) => {
|
||||
promptInputs.push(input)
|
||||
return {}
|
||||
})
|
||||
const handler = createTeamIdleWakeHint({
|
||||
directory: "/tmp/project",
|
||||
client: { session: { promptAsync: promptAsyncSpy } },
|
||||
}, config)
|
||||
|
||||
// when
|
||||
await handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "member-session" },
|
||||
},
|
||||
})
|
||||
|
||||
// then
|
||||
expect(promptAsyncSpy).toHaveBeenCalledTimes(1)
|
||||
const promptInput = promptInputs[0]
|
||||
if (promptInput === undefined) {
|
||||
throw new Error("expected wake hint prompt input")
|
||||
}
|
||||
expect(promptInput.path).toEqual({ id: "member-session" })
|
||||
expect(promptInput.body.parts[0]?.text).toContain("2 new team messages")
|
||||
expect(promptInput.body.parts[0]?.text).not.toContain("first message body")
|
||||
expect(promptInput.body.parts[0]?.text).not.toContain("second message body")
|
||||
})
|
||||
|
||||
test("pins the recipient's resolved subagent_type and model on the wake-hint promptAsync", async () => {
|
||||
// given
|
||||
const baseDir = await createTemporaryBaseDir()
|
||||
const config = createConfig(baseDir)
|
||||
const teamRunId = randomUUID()
|
||||
const runtimeState = createRuntimeState(teamRunId)
|
||||
const worker = runtimeState.members[0]
|
||||
if (!worker) throw new Error("worker member missing from fixture")
|
||||
worker.subagent_type = "atlas"
|
||||
worker.model = { providerID: "anthropic", modelID: "claude-opus-4-7", variant: "high" }
|
||||
await seedRuntimeState(runtimeState, config)
|
||||
await seedUnreadMessage(teamRunId, config, randomUUID(), "hello", 100)
|
||||
|
||||
const promptInputs: Array<WakeHintPromptInput> = []
|
||||
const promptAsyncSpy = mock(async (input: WakeHintPromptInput) => {
|
||||
promptInputs.push(input)
|
||||
return {}
|
||||
})
|
||||
const handler = createTeamIdleWakeHint({
|
||||
directory: "/tmp/project",
|
||||
client: { session: { promptAsync: promptAsyncSpy } },
|
||||
}, config)
|
||||
|
||||
// when
|
||||
await handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "member-session" },
|
||||
},
|
||||
})
|
||||
|
||||
// then
|
||||
expect(promptAsyncSpy).toHaveBeenCalledTimes(1)
|
||||
const promptInput = promptInputs[0]
|
||||
if (promptInput === undefined) {
|
||||
throw new Error("expected wake hint prompt input")
|
||||
}
|
||||
expect(promptInput.body.agent).toBe("atlas")
|
||||
expect(promptInput.body.model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-7" })
|
||||
expect(promptInput.body.variant).toBe("high")
|
||||
})
|
||||
|
||||
test("reapplies category routing and advanced prompt params on wake hints", async () => {
|
||||
// given
|
||||
const baseDir = await createTemporaryBaseDir()
|
||||
const config = createConfig(baseDir)
|
||||
const teamRunId = randomUUID()
|
||||
const runtimeState = createRuntimeState(teamRunId)
|
||||
const worker = runtimeState.members[0]
|
||||
if (!worker) throw new Error("worker member missing from fixture")
|
||||
worker.subagent_type = "Sisyphus-Junior"
|
||||
worker.category = "quick"
|
||||
worker.model = {
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.4",
|
||||
variant: "medium",
|
||||
reasoningEffort: "high",
|
||||
temperature: 0.2,
|
||||
top_p: 0.8,
|
||||
maxTokens: 4096,
|
||||
thinking: { type: "enabled", budgetTokens: 2048 },
|
||||
}
|
||||
await seedRuntimeState(runtimeState, config)
|
||||
await seedUnreadMessage(teamRunId, config, randomUUID(), "hello", 100)
|
||||
|
||||
const promptInputs: Array<WakeHintPromptInput> = []
|
||||
const promptAsyncSpy = mock(async (input: WakeHintPromptInput) => {
|
||||
promptInputs.push(input)
|
||||
return {}
|
||||
})
|
||||
const handler = createTeamIdleWakeHint({
|
||||
directory: "/tmp/project",
|
||||
client: { session: { promptAsync: promptAsyncSpy } },
|
||||
}, config)
|
||||
|
||||
// when
|
||||
await handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "member-session" },
|
||||
},
|
||||
})
|
||||
|
||||
// then
|
||||
expect(promptAsyncSpy).toHaveBeenCalledTimes(1)
|
||||
const promptInput = promptInputs[0]
|
||||
if (promptInput === undefined) {
|
||||
throw new Error("expected wake hint prompt input")
|
||||
}
|
||||
expect(promptInput.body.agent).toBe("Sisyphus-Junior")
|
||||
expect(promptInput.body.model).toEqual({ providerID: "openai", modelID: "gpt-5.4" })
|
||||
expect(promptInput.body.variant).toBe("medium")
|
||||
expect(promptInput.body.temperature).toBe(0.2)
|
||||
expect(promptInput.body.topP).toBe(0.8)
|
||||
expect(promptInput.body.maxOutputTokens).toBe(4096)
|
||||
expect(promptInput.body.options).toEqual({
|
||||
reasoningEffort: "high",
|
||||
thinking: { type: "enabled", budgetTokens: 2048 },
|
||||
})
|
||||
expect(SessionCategoryRegistry.get("member-session")).toBe("quick")
|
||||
expect(getSessionPromptParams("member-session")).toEqual({
|
||||
temperature: 0.2,
|
||||
topP: 0.8,
|
||||
maxOutputTokens: 4096,
|
||||
options: {
|
||||
reasoningEffort: "high",
|
||||
thinking: { type: "enabled", budgetTokens: 2048 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("omits agent and model on the wake-hint promptAsync when the member has none recorded", async () => {
|
||||
// given
|
||||
const baseDir = await createTemporaryBaseDir()
|
||||
const config = createConfig(baseDir)
|
||||
const teamRunId = randomUUID()
|
||||
await seedRuntimeState(createRuntimeState(teamRunId), config)
|
||||
await seedUnreadMessage(teamRunId, config, randomUUID(), "hello", 100)
|
||||
|
||||
const promptInputs: Array<WakeHintPromptInput> = []
|
||||
const promptAsyncSpy = mock(async (input: WakeHintPromptInput) => {
|
||||
promptInputs.push(input)
|
||||
return {}
|
||||
})
|
||||
const handler = createTeamIdleWakeHint({
|
||||
directory: "/tmp/project",
|
||||
client: { session: { promptAsync: promptAsyncSpy } },
|
||||
}, config)
|
||||
|
||||
// when
|
||||
await handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "member-session" },
|
||||
},
|
||||
})
|
||||
|
||||
// then
|
||||
expect(promptAsyncSpy).toHaveBeenCalledTimes(1)
|
||||
const promptInput = promptInputs[0]
|
||||
if (promptInput === undefined) {
|
||||
throw new Error("expected wake hint prompt input")
|
||||
}
|
||||
expect(promptInput.body.agent).toBeUndefined()
|
||||
expect(promptInput.body.model).toBeUndefined()
|
||||
expect(promptInput.body.variant).toBeUndefined()
|
||||
})
|
||||
|
||||
test("acks pending messages on idle, moves files to processed, and clears pending ids", async () => {
|
||||
// given
|
||||
const baseDir = await createTemporaryBaseDir()
|
||||
const config = createConfig(baseDir)
|
||||
const teamRunId = randomUUID()
|
||||
const messageIds = [randomUUID(), randomUUID(), randomUUID()]
|
||||
await seedRuntimeState(createRuntimeState(teamRunId, messageIds), config)
|
||||
await seedUnreadMessage(teamRunId, config, messageIds[0], "one", 100)
|
||||
await seedUnreadMessage(teamRunId, config, messageIds[1], "two", 200)
|
||||
await seedUnreadMessage(teamRunId, config, messageIds[2], "three", 300)
|
||||
|
||||
const ackSpy = spyOn(ackModule, "ackMessages")
|
||||
const promptAsyncSpy = mock(async (_input: {
|
||||
path: { id: string }
|
||||
body: { parts: Array<{ type: "text"; text: string }> }
|
||||
query: { directory: string }
|
||||
}) => {
|
||||
return {}
|
||||
})
|
||||
const handler = createTeamIdleWakeHint({
|
||||
directory: "/tmp/project",
|
||||
client: { session: { promptAsync: promptAsyncSpy } },
|
||||
}, config)
|
||||
|
||||
// when
|
||||
await handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "member-session" },
|
||||
},
|
||||
})
|
||||
|
||||
// then
|
||||
expect(ackSpy).toHaveBeenCalledTimes(1)
|
||||
expect(ackSpy).toHaveBeenCalledWith(teamRunId, "worker", messageIds, config)
|
||||
expect(promptAsyncSpy).not.toHaveBeenCalled()
|
||||
|
||||
const runtimeState = await loadRuntimeState(teamRunId, config)
|
||||
expect(runtimeState.members[0]?.pendingInjectedMessageIds).toEqual([])
|
||||
|
||||
const inboxEntries = await readdir(getInboxDir(resolveBaseDir(config), teamRunId, "worker"))
|
||||
expect(inboxEntries).toContain("processed")
|
||||
|
||||
const processedEntries = await readdir(path.join(getInboxDir(resolveBaseDir(config), teamRunId, "worker"), "processed"))
|
||||
expect(processedEntries.sort()).toEqual(messageIds.map((messageId) => `${messageId}.json`).sort())
|
||||
})
|
||||
|
||||
test("sends a wake hint during the spawn race when the registry tracks the fresh member session before disk state persists it", async () => {
|
||||
// given
|
||||
const baseDir = await createTemporaryBaseDir()
|
||||
const config = createConfig(baseDir)
|
||||
const teamRunId = randomUUID()
|
||||
const staleRuntimeState: RuntimeState = {
|
||||
...createRuntimeState(teamRunId),
|
||||
members: [
|
||||
{
|
||||
name: "worker",
|
||||
agentType: "general-purpose",
|
||||
status: "idle",
|
||||
pendingInjectedMessageIds: [],
|
||||
},
|
||||
],
|
||||
}
|
||||
await seedRuntimeState(staleRuntimeState, config)
|
||||
await seedUnreadMessage(teamRunId, config, randomUUID(), "fresh registry wake hint", 100)
|
||||
registerTeamSession("member-session", {
|
||||
teamRunId,
|
||||
memberName: "worker",
|
||||
role: "member",
|
||||
})
|
||||
|
||||
const promptInputs: Array<WakeHintPromptInput> = []
|
||||
const promptAsyncSpy = mock(async (input: WakeHintPromptInput) => {
|
||||
promptInputs.push(input)
|
||||
return {}
|
||||
})
|
||||
const handler = createTeamIdleWakeHint({
|
||||
directory: "/tmp/project",
|
||||
client: { session: { promptAsync: promptAsyncSpy } },
|
||||
}, config)
|
||||
|
||||
// when
|
||||
await handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "member-session" },
|
||||
},
|
||||
})
|
||||
|
||||
// then
|
||||
expect(promptAsyncSpy).toHaveBeenCalledTimes(1)
|
||||
const promptInput = promptInputs[0]
|
||||
if (promptInput === undefined) {
|
||||
throw new Error("expected wake hint prompt input")
|
||||
}
|
||||
expect(promptInput.body.parts[0]?.text).toContain("1 new team messages")
|
||||
})
|
||||
|
||||
test("falls back to disk lookup when the registry points the member session at the wrong teamRunId", async () => {
|
||||
// given
|
||||
const baseDir = await createTemporaryBaseDir()
|
||||
const config = createConfig(baseDir)
|
||||
const correctTeamRunId = randomUUID()
|
||||
const wrongTeamRunId = randomUUID()
|
||||
const correctRuntimeState = createRuntimeState(correctTeamRunId)
|
||||
const correctWorker = correctRuntimeState.members[0]
|
||||
if (correctWorker === undefined) {
|
||||
throw new Error("worker member missing from correct fixture")
|
||||
}
|
||||
correctWorker.subagent_type = "atlas"
|
||||
await seedRuntimeState(correctRuntimeState, config)
|
||||
await seedRuntimeState({
|
||||
...createRuntimeState(wrongTeamRunId),
|
||||
members: [
|
||||
{
|
||||
name: "worker",
|
||||
sessionId: "other-session",
|
||||
agentType: "general-purpose",
|
||||
status: "idle",
|
||||
pendingInjectedMessageIds: [],
|
||||
},
|
||||
],
|
||||
}, config)
|
||||
await seedUnreadMessage(correctTeamRunId, config, randomUUID(), "first correct message", 100)
|
||||
await seedUnreadMessage(correctTeamRunId, config, randomUUID(), "second correct message", 200)
|
||||
await seedUnreadMessage(wrongTeamRunId, config, randomUUID(), "wrong team message", 300)
|
||||
registerTeamSession("member-session", {
|
||||
teamRunId: wrongTeamRunId,
|
||||
memberName: "worker",
|
||||
role: "member",
|
||||
})
|
||||
|
||||
const promptInputs: Array<WakeHintPromptInput> = []
|
||||
const promptAsyncSpy = mock(async (input: WakeHintPromptInput) => {
|
||||
promptInputs.push(input)
|
||||
return {}
|
||||
})
|
||||
const handler = createTeamIdleWakeHint({
|
||||
directory: "/tmp/project",
|
||||
client: { session: { promptAsync: promptAsyncSpy } },
|
||||
}, config)
|
||||
|
||||
// when
|
||||
await handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "member-session" },
|
||||
},
|
||||
})
|
||||
|
||||
// then
|
||||
expect(promptAsyncSpy).toHaveBeenCalledTimes(1)
|
||||
const promptInput = promptInputs[0]
|
||||
if (promptInput === undefined) {
|
||||
throw new Error("expected wake hint prompt input")
|
||||
}
|
||||
expect(promptInput.body.parts[0]?.text).toContain("2 new team messages")
|
||||
expect(promptInput.body.agent).toBe("atlas")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { TeamModeConfig } from "../../config/schema/team-mode"
|
||||
import { ackMessages } from "../../features/team-mode/team-mailbox/ack"
|
||||
import { listUnreadMessages } from "../../features/team-mode/team-mailbox/inbox"
|
||||
import { loadRuntimeState, listActiveTeams, transitionRuntimeState } from "../../features/team-mode/team-state-store/store"
|
||||
import { findResolvedMemberSession } from "../../features/team-mode/member-session-resolution"
|
||||
import {
|
||||
applyMemberSessionRouting,
|
||||
buildMemberPromptBody,
|
||||
} from "../../features/team-mode/member-session-routing"
|
||||
import { log } from "../../shared/logger"
|
||||
import { settleAfterSessionIdle } from "../shared/session-idle-settle"
|
||||
|
||||
type PromptAsyncInput = {
|
||||
path: { id: string }
|
||||
body: {
|
||||
parts: Array<{ type: "text"; text: string }>
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
variant?: string
|
||||
}
|
||||
query: { directory: string }
|
||||
}
|
||||
|
||||
type TeamIdleWakeHintContext = {
|
||||
directory: string
|
||||
client: {
|
||||
session: {
|
||||
promptAsync?: (input: PromptAsyncInput) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type HookInput = { event: { type: string; properties?: unknown } }
|
||||
export type HookImpl = (input: HookInput) => Promise<void>
|
||||
type TeamIdleWakeHintOptions = { idleSettleMs?: number }
|
||||
|
||||
function getIdleSessionID(properties: unknown): string | undefined {
|
||||
const record = properties as { sessionID?: string } | undefined
|
||||
return record?.sessionID
|
||||
}
|
||||
|
||||
function buildWakeHint(unreadCount: number): string {
|
||||
return `You have ${unreadCount} new team messages. They will be injected on your next turn.`
|
||||
}
|
||||
|
||||
export function createTeamIdleWakeHint(ctx: TeamIdleWakeHintContext, config: TeamModeConfig, options?: TeamIdleWakeHintOptions): HookImpl {
|
||||
return async ({ event }: HookInput): Promise<void> => {
|
||||
if (event.type !== "session.idle") return
|
||||
|
||||
const sessionID = getIdleSessionID(event.properties)
|
||||
if (!sessionID) return
|
||||
|
||||
try {
|
||||
const runtimeMember = await findResolvedMemberSession(sessionID, config, "team idle wake hint")
|
||||
if (runtimeMember === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const runtimeState = await loadRuntimeState(runtimeMember.teamRunId, config)
|
||||
const memberEntry = runtimeState.members.find((member) => member.name === runtimeMember.memberName)
|
||||
if (!memberEntry || memberEntry.agentType === "leader") {
|
||||
return
|
||||
}
|
||||
|
||||
const pendingInjectedMessageIds = [...memberEntry.pendingInjectedMessageIds]
|
||||
if (pendingInjectedMessageIds.length > 0) {
|
||||
await ackMessages(runtimeState.teamRunId, memberEntry.name, pendingInjectedMessageIds, config)
|
||||
await transitionRuntimeState(runtimeState.teamRunId, (currentRuntimeState) => ({
|
||||
...currentRuntimeState,
|
||||
members: currentRuntimeState.members.map((member) => (
|
||||
member.name === memberEntry.name
|
||||
? { ...member, pendingInjectedMessageIds: [] }
|
||||
: member
|
||||
)),
|
||||
}), config)
|
||||
}
|
||||
|
||||
const unreadMessages = await listUnreadMessages(runtimeState.teamRunId, memberEntry.name, config)
|
||||
if (unreadMessages.length === 0) {
|
||||
log("team idle handled without wake hint", {
|
||||
event: "team-mode-idle-ack-only",
|
||||
teamRunId: runtimeState.teamRunId,
|
||||
memberName: memberEntry.name,
|
||||
sessionID,
|
||||
ackedCount: pendingInjectedMessageIds.length,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof ctx.client.session.promptAsync !== "function") {
|
||||
log("team idle wake hint skipped without promptAsync", {
|
||||
event: "team-mode-idle-wake-hint-skipped",
|
||||
teamRunId: runtimeState.teamRunId,
|
||||
memberName: memberEntry.name,
|
||||
sessionID,
|
||||
unreadCount: unreadMessages.length,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
applyMemberSessionRouting(sessionID, memberEntry)
|
||||
await settleAfterSessionIdle(options?.idleSettleMs)
|
||||
|
||||
await ctx.client.session.promptAsync({
|
||||
path: { id: sessionID },
|
||||
body: buildMemberPromptBody(memberEntry, buildWakeHint(unreadMessages.length)),
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
|
||||
log("team idle wake hint sent", {
|
||||
event: "team-mode-idle-wake-hint",
|
||||
teamRunId: runtimeState.teamRunId,
|
||||
memberName: memberEntry.name,
|
||||
sessionID,
|
||||
unreadCount: unreadMessages.length,
|
||||
ackedCount: pendingInjectedMessageIds.length,
|
||||
})
|
||||
} catch (error) {
|
||||
log("team idle wake hint failed", {
|
||||
event: "team-mode-idle-wake-hint-error",
|
||||
sessionID,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { mkdtemp, mkdir, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
import { TeamModeConfigSchema } from "../../config/schema/team-mode"
|
||||
import type { TeamModeConfig } from "../../config/schema/team-mode"
|
||||
import * as deleteTeamModule from "../../features/team-mode/team-runtime/delete-team"
|
||||
import {
|
||||
clearTeamSessionRegistry,
|
||||
registerTeamSession,
|
||||
} from "../../features/team-mode/team-session-registry"
|
||||
import type { RuntimeState } from "../../features/team-mode/types"
|
||||
import { loadRuntimeState, saveRuntimeState } from "../../features/team-mode/team-state-store/store"
|
||||
import { createTeamLeadOrphanHandler } from "./team-lead-orphan-handler"
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
async function createTemporaryBaseDir(): Promise<string> {
|
||||
const baseDir = await mkdtemp(path.join(tmpdir(), "team-lead-orphan-handler-"))
|
||||
temporaryDirectories.push(baseDir)
|
||||
return baseDir
|
||||
}
|
||||
|
||||
function createConfig(baseDir: string): TeamModeConfig {
|
||||
return TeamModeConfigSchema.parse({ base_dir: baseDir, enabled: true })
|
||||
}
|
||||
|
||||
function createRuntimeState(teamRunId: string): RuntimeState {
|
||||
return {
|
||||
version: 1,
|
||||
teamRunId,
|
||||
teamName: "team-alpha",
|
||||
specSource: "project",
|
||||
createdAt: 1,
|
||||
status: "active",
|
||||
leadSessionId: "lead-session",
|
||||
members: [
|
||||
{
|
||||
name: "worker",
|
||||
sessionId: "member-session",
|
||||
agentType: "general-purpose",
|
||||
status: "running",
|
||||
pendingInjectedMessageIds: [],
|
||||
},
|
||||
],
|
||||
shutdownRequests: [],
|
||||
bounds: {
|
||||
maxMembers: 8,
|
||||
maxParallelMembers: 4,
|
||||
maxMessagesPerRun: 10000,
|
||||
maxWallClockMinutes: 120,
|
||||
maxMemberTurns: 500,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function seedRuntimeState(runtimeState: RuntimeState, config: TeamModeConfig): Promise<void> {
|
||||
await mkdir(path.join(config.base_dir ?? "", "runtime", runtimeState.teamRunId), { recursive: true })
|
||||
await saveRuntimeState(runtimeState, config)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
mock.restore()
|
||||
clearTeamSessionRegistry()
|
||||
await Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => {
|
||||
await rm(directoryPath, { recursive: true, force: true })
|
||||
}))
|
||||
})
|
||||
|
||||
describe("createTeamLeadOrphanHandler", () => {
|
||||
test("#given the deleted session matches the lead #when the orphan handler runs #then it marks the team orphaned and force-deletes the team", async () => {
|
||||
// given
|
||||
const baseDir = await createTemporaryBaseDir()
|
||||
const config = createConfig(baseDir)
|
||||
const teamRunId = randomUUID()
|
||||
await seedRuntimeState(createRuntimeState(teamRunId), config)
|
||||
const deleteTeamSpy = spyOn(deleteTeamModule, "deleteTeam")
|
||||
deleteTeamSpy.mockResolvedValue({ removedLayout: true, removedWorktrees: [] })
|
||||
const handler = createTeamLeadOrphanHandler(config)
|
||||
|
||||
// when
|
||||
await handler({
|
||||
event: {
|
||||
type: "session.deleted",
|
||||
properties: { info: { id: "lead-session" } },
|
||||
},
|
||||
})
|
||||
|
||||
// then
|
||||
const runtimeState = await loadRuntimeState(teamRunId, config)
|
||||
expect(runtimeState.status).toBe("orphaned")
|
||||
expect(deleteTeamSpy).toHaveBeenCalledTimes(1)
|
||||
expect(deleteTeamSpy).toHaveBeenCalledWith(teamRunId, config, undefined, undefined, { force: true })
|
||||
})
|
||||
|
||||
test("#given the registry tracks a fresh lead session before disk state persists it #when the orphan handler runs #then it still marks the team orphaned and force-deletes it", async () => {
|
||||
// given
|
||||
const baseDir = await createTemporaryBaseDir()
|
||||
const config = createConfig(baseDir)
|
||||
const teamRunId = randomUUID()
|
||||
await seedRuntimeState({
|
||||
...createRuntimeState(teamRunId),
|
||||
leadSessionId: undefined,
|
||||
}, config)
|
||||
registerTeamSession("lead-session", {
|
||||
teamRunId,
|
||||
memberName: "lead",
|
||||
role: "lead",
|
||||
})
|
||||
const deleteTeamSpy = spyOn(deleteTeamModule, "deleteTeam")
|
||||
deleteTeamSpy.mockResolvedValue({ removedLayout: false, removedWorktrees: [] })
|
||||
const handler = createTeamLeadOrphanHandler(config)
|
||||
|
||||
// when
|
||||
await handler({
|
||||
event: {
|
||||
type: "session.deleted",
|
||||
properties: { info: { id: "lead-session" } },
|
||||
},
|
||||
})
|
||||
|
||||
// then
|
||||
const runtimeState = await loadRuntimeState(teamRunId, config)
|
||||
expect(runtimeState.status).toBe("orphaned")
|
||||
expect(deleteTeamSpy).toHaveBeenCalledTimes(1)
|
||||
expect(deleteTeamSpy).toHaveBeenCalledWith(teamRunId, config, undefined, undefined, { force: true })
|
||||
})
|
||||
|
||||
test("#given the registry points the lead session at the wrong teamRunId #when the orphan handler runs #then it falls back to disk lookup, orphans the correct team, and force-deletes it", async () => {
|
||||
// given
|
||||
const baseDir = await createTemporaryBaseDir()
|
||||
const config = createConfig(baseDir)
|
||||
const correctTeamRunId = randomUUID()
|
||||
const wrongTeamRunId = randomUUID()
|
||||
await seedRuntimeState(createRuntimeState(correctTeamRunId), config)
|
||||
await seedRuntimeState({
|
||||
...createRuntimeState(wrongTeamRunId),
|
||||
leadSessionId: "other-lead-session",
|
||||
}, config)
|
||||
registerTeamSession("lead-session", {
|
||||
teamRunId: wrongTeamRunId,
|
||||
memberName: "lead",
|
||||
role: "lead",
|
||||
})
|
||||
const deleteTeamSpy = spyOn(deleteTeamModule, "deleteTeam")
|
||||
deleteTeamSpy.mockResolvedValue({ removedLayout: false, removedWorktrees: [] })
|
||||
const handler = createTeamLeadOrphanHandler(config)
|
||||
|
||||
// when
|
||||
await handler({
|
||||
event: {
|
||||
type: "session.deleted",
|
||||
properties: { info: { id: "lead-session" } },
|
||||
},
|
||||
})
|
||||
|
||||
// then
|
||||
const correctRuntimeState = await loadRuntimeState(correctTeamRunId, config)
|
||||
const wrongRuntimeState = await loadRuntimeState(wrongTeamRunId, config)
|
||||
expect(correctRuntimeState.status).toBe("orphaned")
|
||||
expect(wrongRuntimeState.status).toBe("active")
|
||||
expect(deleteTeamSpy).toHaveBeenCalledTimes(1)
|
||||
expect(deleteTeamSpy).toHaveBeenCalledWith(correctTeamRunId, config, undefined, undefined, { force: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { TeamModeConfig } from "../../config/schema/team-mode"
|
||||
import type { BackgroundManager } from "../../features/background-agent/manager"
|
||||
import { lookupTeamSession } from "../../features/team-mode/team-session-registry"
|
||||
import { loadRuntimeState, listActiveTeams, transitionRuntimeState } from "../../features/team-mode/team-state-store/store"
|
||||
import type { TmuxSessionManager } from "../../features/tmux-subagent/manager"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
type HookInput = { event: { type: string; properties?: unknown } }
|
||||
export type HookImpl = (input: HookInput) => Promise<void>
|
||||
|
||||
function getDeletedSessionID(properties: unknown): string | undefined {
|
||||
const record = properties as { info?: { id?: string } } | undefined
|
||||
return record?.info?.id
|
||||
}
|
||||
|
||||
async function findLeadTeamRunId(
|
||||
deletedSessionID: string,
|
||||
config: TeamModeConfig,
|
||||
): Promise<string | null> {
|
||||
const registryEntry = lookupTeamSession(deletedSessionID)
|
||||
if (registryEntry?.role === "lead") {
|
||||
try {
|
||||
const runtimeState = await loadRuntimeState(registryEntry.teamRunId, config)
|
||||
if (runtimeState.leadSessionId === undefined || runtimeState.leadSessionId === deletedSessionID) {
|
||||
return runtimeState.teamRunId
|
||||
}
|
||||
} catch (error) {
|
||||
log("team lead orphan handler registry lookup failed", {
|
||||
event: "team-mode-lead-orphan-handler-registry-error",
|
||||
teamRunId: registryEntry.teamRunId,
|
||||
deletedSessionID,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const activeTeams = await listActiveTeams(config)
|
||||
|
||||
for (const activeTeam of activeTeams) {
|
||||
try {
|
||||
const runtimeState = await loadRuntimeState(activeTeam.teamRunId, config)
|
||||
if (runtimeState.leadSessionId === deletedSessionID) {
|
||||
return runtimeState.teamRunId
|
||||
}
|
||||
} catch (error) {
|
||||
log("team lead orphan handler skipped runtime", {
|
||||
event: "team-mode-lead-orphan-handler-runtime-error",
|
||||
teamRunId: activeTeam.teamRunId,
|
||||
deletedSessionID,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function createTeamLeadOrphanHandler(
|
||||
config: TeamModeConfig,
|
||||
tmuxMgr?: TmuxSessionManager,
|
||||
bgMgr?: BackgroundManager,
|
||||
): HookImpl {
|
||||
return async ({ event }: HookInput): Promise<void> => {
|
||||
if (event.type !== "session.deleted") return
|
||||
|
||||
const deletedSessionID = getDeletedSessionID(event.properties)
|
||||
if (!deletedSessionID) return
|
||||
|
||||
try {
|
||||
const teamRunId = await findLeadTeamRunId(deletedSessionID, config)
|
||||
if (teamRunId === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const runtimeState = await loadRuntimeState(teamRunId, config)
|
||||
const nextRuntimeState = await transitionRuntimeState(runtimeState.teamRunId, (currentRuntimeState) => ({
|
||||
...currentRuntimeState,
|
||||
status: "orphaned",
|
||||
}), config)
|
||||
|
||||
log("team lead session deleted", {
|
||||
event: "team-mode-lead-orphaned",
|
||||
teamRunId: runtimeState.teamRunId,
|
||||
teamName: runtimeState.teamName,
|
||||
deletedSessionID,
|
||||
previousStatus: runtimeState.status,
|
||||
nextStatus: nextRuntimeState.status,
|
||||
})
|
||||
|
||||
try {
|
||||
const { deleteTeam } = await import("../../features/team-mode/team-runtime/delete-team")
|
||||
await deleteTeam(teamRunId, config, tmuxMgr, bgMgr, { force: true })
|
||||
} catch (deleteError) {
|
||||
log("team lead orphan cleanup failed (non-fatal)", {
|
||||
event: "team-mode-lead-orphan-cleanup-error",
|
||||
teamRunId,
|
||||
error: deleteError instanceof Error ? deleteError.message : String(deleteError),
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
log("team lead orphan handler failed", {
|
||||
event: "team-mode-lead-orphan-handler-error",
|
||||
deletedSessionID,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { mkdtemp, mkdir, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
import { TeamModeConfigSchema } from "../../config/schema/team-mode"
|
||||
import type { TeamModeConfig } from "../../config/schema/team-mode"
|
||||
import {
|
||||
clearTeamSessionRegistry,
|
||||
registerTeamSession,
|
||||
} from "../../features/team-mode/team-session-registry"
|
||||
import type { RuntimeState } from "../../features/team-mode/types"
|
||||
import { loadRuntimeState, saveRuntimeState } from "../../features/team-mode/team-state-store/store"
|
||||
import { createTeamMemberErrorHandler } from "./team-member-error-handler"
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
async function createTemporaryBaseDir(): Promise<string> {
|
||||
const baseDir = await mkdtemp(path.join(tmpdir(), "team-member-error-handler-"))
|
||||
temporaryDirectories.push(baseDir)
|
||||
return baseDir
|
||||
}
|
||||
|
||||
function createConfig(baseDir: string): TeamModeConfig {
|
||||
return TeamModeConfigSchema.parse({ base_dir: baseDir, enabled: true })
|
||||
}
|
||||
|
||||
function createRuntimeState(teamRunId: string): RuntimeState {
|
||||
return {
|
||||
version: 1,
|
||||
teamRunId,
|
||||
teamName: "team-alpha",
|
||||
specSource: "project",
|
||||
createdAt: 1,
|
||||
status: "active",
|
||||
leadSessionId: "lead-session",
|
||||
members: [
|
||||
{
|
||||
name: "worker",
|
||||
sessionId: "member-session",
|
||||
agentType: "general-purpose",
|
||||
status: "running",
|
||||
pendingInjectedMessageIds: [],
|
||||
},
|
||||
],
|
||||
shutdownRequests: [],
|
||||
bounds: {
|
||||
maxMembers: 8,
|
||||
maxParallelMembers: 4,
|
||||
maxMessagesPerRun: 10000,
|
||||
maxWallClockMinutes: 120,
|
||||
maxMemberTurns: 500,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function seedRuntimeState(runtimeState: RuntimeState, config: TeamModeConfig): Promise<void> {
|
||||
await mkdir(path.join(config.base_dir ?? "", "runtime", runtimeState.teamRunId), { recursive: true })
|
||||
await saveRuntimeState(runtimeState, config)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
clearTeamSessionRegistry()
|
||||
await Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => {
|
||||
await rm(directoryPath, { recursive: true, force: true })
|
||||
}))
|
||||
})
|
||||
|
||||
describe("createTeamMemberErrorHandler", () => {
|
||||
test("marks the matching member errored without changing team status", async () => {
|
||||
// given
|
||||
const baseDir = await createTemporaryBaseDir()
|
||||
const config = createConfig(baseDir)
|
||||
const teamRunId = randomUUID()
|
||||
await seedRuntimeState(createRuntimeState(teamRunId), config)
|
||||
const handler = createTeamMemberErrorHandler(config)
|
||||
|
||||
// when
|
||||
await handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID: "member-session", error: new Error("boom") },
|
||||
},
|
||||
})
|
||||
|
||||
// then
|
||||
const runtimeState = await loadRuntimeState(teamRunId, config)
|
||||
expect(runtimeState.status).toBe("active")
|
||||
expect(runtimeState.members[0]?.status).toBe("errored")
|
||||
})
|
||||
|
||||
test("marks the member errored during the spawn race when the registry tracks the fresh session before disk state persists it", async () => {
|
||||
// given
|
||||
const baseDir = await createTemporaryBaseDir()
|
||||
const config = createConfig(baseDir)
|
||||
const teamRunId = randomUUID()
|
||||
await seedRuntimeState({
|
||||
...createRuntimeState(teamRunId),
|
||||
members: [
|
||||
{
|
||||
name: "worker",
|
||||
agentType: "general-purpose",
|
||||
status: "running",
|
||||
pendingInjectedMessageIds: [],
|
||||
},
|
||||
],
|
||||
}, config)
|
||||
registerTeamSession("member-session", {
|
||||
teamRunId,
|
||||
memberName: "worker",
|
||||
role: "member",
|
||||
})
|
||||
const handler = createTeamMemberErrorHandler(config)
|
||||
|
||||
// when
|
||||
await handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID: "member-session", error: new Error("boom") },
|
||||
},
|
||||
})
|
||||
|
||||
// then
|
||||
const runtimeState = await loadRuntimeState(teamRunId, config)
|
||||
expect(runtimeState.status).toBe("active")
|
||||
expect(runtimeState.members[0]?.status).toBe("errored")
|
||||
})
|
||||
|
||||
test("falls back to disk lookup when the registry points the member session at the wrong teamRunId", async () => {
|
||||
// given
|
||||
const baseDir = await createTemporaryBaseDir()
|
||||
const config = createConfig(baseDir)
|
||||
const correctTeamRunId = randomUUID()
|
||||
const wrongTeamRunId = randomUUID()
|
||||
await seedRuntimeState(createRuntimeState(correctTeamRunId), config)
|
||||
await seedRuntimeState({
|
||||
...createRuntimeState(wrongTeamRunId),
|
||||
members: [
|
||||
{
|
||||
name: "worker",
|
||||
sessionId: "other-session",
|
||||
agentType: "general-purpose",
|
||||
status: "running",
|
||||
pendingInjectedMessageIds: [],
|
||||
},
|
||||
],
|
||||
}, config)
|
||||
registerTeamSession("member-session", {
|
||||
teamRunId: wrongTeamRunId,
|
||||
memberName: "worker",
|
||||
role: "member",
|
||||
})
|
||||
const handler = createTeamMemberErrorHandler(config)
|
||||
|
||||
// when
|
||||
await handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID: "member-session", error: new Error("boom") },
|
||||
},
|
||||
})
|
||||
|
||||
// then
|
||||
const correctRuntimeState = await loadRuntimeState(correctTeamRunId, config)
|
||||
const wrongRuntimeState = await loadRuntimeState(wrongTeamRunId, config)
|
||||
expect(correctRuntimeState.members[0]?.status).toBe("errored")
|
||||
expect(wrongRuntimeState.members[0]?.status).toBe("running")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { TeamModeConfig } from "../../config/schema/team-mode"
|
||||
import { findResolvedMemberSession } from "../../features/team-mode/member-session-resolution"
|
||||
import { loadRuntimeState, transitionRuntimeState } from "../../features/team-mode/team-state-store/store"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
type HookInput = { event: { type: string; properties?: unknown } }
|
||||
export type HookImpl = (input: HookInput) => Promise<void>
|
||||
|
||||
function getErroredSessionID(properties: unknown): string | undefined {
|
||||
const record = properties as { sessionID?: string } | undefined
|
||||
return record?.sessionID
|
||||
}
|
||||
|
||||
export function createTeamMemberErrorHandler(config: TeamModeConfig): HookImpl {
|
||||
return async ({ event }: HookInput): Promise<void> => {
|
||||
if (event.type !== "session.error") return
|
||||
|
||||
const erroredSessionID = getErroredSessionID(event.properties)
|
||||
if (!erroredSessionID) return
|
||||
|
||||
try {
|
||||
const runtimeMember = await findResolvedMemberSession(erroredSessionID, config, "team member error handler")
|
||||
if (runtimeMember === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const runtimeState = await loadRuntimeState(runtimeMember.teamRunId, config)
|
||||
await transitionRuntimeState(runtimeState.teamRunId, (currentRuntimeState) => ({
|
||||
...currentRuntimeState,
|
||||
members: currentRuntimeState.members.map((member) => (
|
||||
member.name === runtimeMember.memberName
|
||||
? { ...member, status: "errored" }
|
||||
: member
|
||||
)),
|
||||
}), config)
|
||||
|
||||
log("team member session errored", {
|
||||
event: "team-mode-member-errored",
|
||||
teamRunId: runtimeState.teamRunId,
|
||||
teamName: runtimeState.teamName,
|
||||
memberName: runtimeMember.memberName,
|
||||
sessionID: erroredSessionID,
|
||||
runtimeStatus: runtimeState.status,
|
||||
})
|
||||
} catch (error) {
|
||||
log("team member error handler failed", {
|
||||
event: "team-mode-member-error-handler-error",
|
||||
sessionID: erroredSessionID,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { mkdtemp, mkdir, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
import { TeamModeConfigSchema } from "../../config/schema/team-mode"
|
||||
import type { TeamModeConfig } from "../../config/schema/team-mode"
|
||||
import {
|
||||
clearTeamSessionRegistry,
|
||||
registerTeamSession,
|
||||
} from "../../features/team-mode/team-session-registry"
|
||||
import type { RuntimeState, RuntimeStateMember } from "../../features/team-mode/types"
|
||||
import { loadRuntimeState, saveRuntimeState } from "../../features/team-mode/team-state-store/store"
|
||||
import { createTeamMemberStatusHandler } from "./team-member-status-handler"
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
async function createTemporaryBaseDir(): Promise<string> {
|
||||
const baseDir = await mkdtemp(path.join(tmpdir(), "team-member-status-handler-"))
|
||||
temporaryDirectories.push(baseDir)
|
||||
return baseDir
|
||||
}
|
||||
|
||||
function createConfig(baseDir: string): TeamModeConfig {
|
||||
return TeamModeConfigSchema.parse({ base_dir: baseDir, enabled: true })
|
||||
}
|
||||
|
||||
function buildMember(overrides?: Partial<RuntimeStateMember>): RuntimeStateMember {
|
||||
return {
|
||||
name: "worker",
|
||||
sessionId: "member-session",
|
||||
agentType: "general-purpose",
|
||||
status: "running",
|
||||
pendingInjectedMessageIds: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function createRuntimeState(teamRunId: string, member: RuntimeStateMember = buildMember()): RuntimeState {
|
||||
return {
|
||||
version: 1,
|
||||
teamRunId,
|
||||
teamName: "team-alpha",
|
||||
specSource: "project",
|
||||
createdAt: 1,
|
||||
status: "active",
|
||||
leadSessionId: "lead-session",
|
||||
members: [member],
|
||||
shutdownRequests: [],
|
||||
bounds: {
|
||||
maxMembers: 8,
|
||||
maxParallelMembers: 4,
|
||||
maxMessagesPerRun: 10000,
|
||||
maxWallClockMinutes: 120,
|
||||
maxMemberTurns: 500,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function seedRuntimeState(runtimeState: RuntimeState, config: TeamModeConfig): Promise<void> {
|
||||
await mkdir(path.join(config.base_dir ?? "", "runtime", runtimeState.teamRunId), { recursive: true })
|
||||
await saveRuntimeState(runtimeState, config)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
clearTeamSessionRegistry()
|
||||
await Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => {
|
||||
await rm(directoryPath, { recursive: true, force: true })
|
||||
}))
|
||||
})
|
||||
|
||||
describe("createTeamMemberStatusHandler", () => {
|
||||
test("transitions a running member to idle when its session becomes idle", async () => {
|
||||
// given
|
||||
const baseDir = await createTemporaryBaseDir()
|
||||
const config = createConfig(baseDir)
|
||||
const teamRunId = randomUUID()
|
||||
await seedRuntimeState(createRuntimeState(teamRunId, buildMember({ status: "running" })), config)
|
||||
const handler = createTeamMemberStatusHandler(config)
|
||||
|
||||
// when
|
||||
await handler({ event: { type: "session.idle", properties: { sessionID: "member-session" } } })
|
||||
|
||||
// then
|
||||
const runtimeState = await loadRuntimeState(teamRunId, config)
|
||||
expect(runtimeState.members[0]?.status).toBe("idle")
|
||||
})
|
||||
|
||||
test("leaves an already-idle member untouched on a subsequent session.idle", async () => {
|
||||
// given
|
||||
const baseDir = await createTemporaryBaseDir()
|
||||
const config = createConfig(baseDir)
|
||||
const teamRunId = randomUUID()
|
||||
await seedRuntimeState(createRuntimeState(teamRunId, buildMember({ status: "idle" })), config)
|
||||
const handler = createTeamMemberStatusHandler(config)
|
||||
|
||||
// when
|
||||
await handler({ event: { type: "session.idle", properties: { sessionID: "member-session" } } })
|
||||
|
||||
// then
|
||||
const runtimeState = await loadRuntimeState(teamRunId, config)
|
||||
expect(runtimeState.members[0]?.status).toBe("idle")
|
||||
})
|
||||
|
||||
test("never overrides a terminal errored status on session.idle", async () => {
|
||||
// given
|
||||
const baseDir = await createTemporaryBaseDir()
|
||||
const config = createConfig(baseDir)
|
||||
const teamRunId = randomUUID()
|
||||
await seedRuntimeState(createRuntimeState(teamRunId, buildMember({ status: "errored" })), config)
|
||||
const handler = createTeamMemberStatusHandler(config)
|
||||
|
||||
// when
|
||||
await handler({ event: { type: "session.idle", properties: { sessionID: "member-session" } } })
|
||||
|
||||
// then
|
||||
const runtimeState = await loadRuntimeState(teamRunId, config)
|
||||
expect(runtimeState.members[0]?.status).toBe("errored")
|
||||
})
|
||||
|
||||
test("marks a running member completed when its session is deleted", async () => {
|
||||
// given
|
||||
const baseDir = await createTemporaryBaseDir()
|
||||
const config = createConfig(baseDir)
|
||||
const teamRunId = randomUUID()
|
||||
await seedRuntimeState(createRuntimeState(teamRunId, buildMember({ status: "running" })), config)
|
||||
const handler = createTeamMemberStatusHandler(config)
|
||||
|
||||
// when
|
||||
await handler({ event: { type: "session.deleted", properties: { info: { id: "member-session" } } } })
|
||||
|
||||
// then
|
||||
const runtimeState = await loadRuntimeState(teamRunId, config)
|
||||
expect(runtimeState.members[0]?.status).toBe("completed")
|
||||
})
|
||||
|
||||
test("marks an idle member completed when its session is deleted", async () => {
|
||||
// given
|
||||
const baseDir = await createTemporaryBaseDir()
|
||||
const config = createConfig(baseDir)
|
||||
const teamRunId = randomUUID()
|
||||
await seedRuntimeState(createRuntimeState(teamRunId, buildMember({ status: "idle" })), config)
|
||||
const handler = createTeamMemberStatusHandler(config)
|
||||
|
||||
// when
|
||||
await handler({ event: { type: "session.deleted", properties: { info: { id: "member-session" } } } })
|
||||
|
||||
// then
|
||||
const runtimeState = await loadRuntimeState(teamRunId, config)
|
||||
expect(runtimeState.members[0]?.status).toBe("completed")
|
||||
})
|
||||
|
||||
test("preserves a terminal errored status even when the session is deleted", async () => {
|
||||
// given
|
||||
const baseDir = await createTemporaryBaseDir()
|
||||
const config = createConfig(baseDir)
|
||||
const teamRunId = randomUUID()
|
||||
await seedRuntimeState(createRuntimeState(teamRunId, buildMember({ status: "errored" })), config)
|
||||
const handler = createTeamMemberStatusHandler(config)
|
||||
|
||||
// when
|
||||
await handler({ event: { type: "session.deleted", properties: { info: { id: "member-session" } } } })
|
||||
|
||||
// then
|
||||
const runtimeState = await loadRuntimeState(teamRunId, config)
|
||||
expect(runtimeState.members[0]?.status).toBe("errored")
|
||||
})
|
||||
|
||||
test("ignores session.idle events for sessions that are not team members", async () => {
|
||||
// given
|
||||
const baseDir = await createTemporaryBaseDir()
|
||||
const config = createConfig(baseDir)
|
||||
const teamRunId = randomUUID()
|
||||
await seedRuntimeState(createRuntimeState(teamRunId), config)
|
||||
const handler = createTeamMemberStatusHandler(config)
|
||||
|
||||
// when
|
||||
await handler({ event: { type: "session.idle", properties: { sessionID: "unknown-session" } } })
|
||||
|
||||
// then
|
||||
const runtimeState = await loadRuntimeState(teamRunId, config)
|
||||
expect(runtimeState.members[0]?.status).toBe("running")
|
||||
})
|
||||
|
||||
test("ignores session.deleted events when the deleted session is the team lead", async () => {
|
||||
// given
|
||||
const baseDir = await createTemporaryBaseDir()
|
||||
const config = createConfig(baseDir)
|
||||
const teamRunId = randomUUID()
|
||||
await seedRuntimeState(createRuntimeState(teamRunId), config)
|
||||
registerTeamSession("lead-session", { teamRunId, memberName: "lead", role: "lead" })
|
||||
const handler = createTeamMemberStatusHandler(config)
|
||||
|
||||
// when
|
||||
await handler({ event: { type: "session.deleted", properties: { info: { id: "lead-session" } } } })
|
||||
|
||||
// then
|
||||
const runtimeState = await loadRuntimeState(teamRunId, config)
|
||||
expect(runtimeState.members[0]?.status).toBe("running")
|
||||
})
|
||||
|
||||
test("uses the in-memory registry to recognize a fresh session during the spawn race", async () => {
|
||||
// given
|
||||
const baseDir = await createTemporaryBaseDir()
|
||||
const config = createConfig(baseDir)
|
||||
const teamRunId = randomUUID()
|
||||
await seedRuntimeState(createRuntimeState(teamRunId, buildMember({ sessionId: undefined, status: "running" })), config)
|
||||
registerTeamSession("member-session", { teamRunId, memberName: "worker", role: "member" })
|
||||
const handler = createTeamMemberStatusHandler(config)
|
||||
|
||||
// when
|
||||
await handler({ event: { type: "session.idle", properties: { sessionID: "member-session" } } })
|
||||
|
||||
// then
|
||||
const runtimeState = await loadRuntimeState(teamRunId, config)
|
||||
expect(runtimeState.members[0]?.status).toBe("idle")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { TeamModeConfig } from "../../config/schema/team-mode"
|
||||
import { findResolvedMemberSession } from "../../features/team-mode/member-session-resolution"
|
||||
import { loadRuntimeState, transitionRuntimeState } from "../../features/team-mode/team-state-store/store"
|
||||
import type { RuntimeStateMember } from "../../features/team-mode/types"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
type HookInput = { event: { type: string; properties?: unknown } }
|
||||
export type HookImpl = (input: HookInput) => Promise<void>
|
||||
|
||||
type MemberStatus = RuntimeStateMember["status"]
|
||||
|
||||
const IDLE_TRANSITION_SOURCE_STATUSES: ReadonlySet<MemberStatus> = new Set(["running"])
|
||||
const COMPLETED_TRANSITION_SOURCE_STATUSES: ReadonlySet<MemberStatus> = new Set(["running", "idle", "pending"])
|
||||
|
||||
function getSessionIDFromIdleEvent(properties: unknown): string | undefined {
|
||||
const record = properties as { sessionID?: string } | undefined
|
||||
return record?.sessionID
|
||||
}
|
||||
|
||||
function getSessionIDFromDeletedEvent(properties: unknown): string | undefined {
|
||||
const record = properties as { info?: { id?: string } } | undefined
|
||||
return record?.info?.id
|
||||
}
|
||||
|
||||
async function transitionMemberStatus(
|
||||
runtimeMember: { teamRunId: string; memberName: string },
|
||||
allowedSources: ReadonlySet<MemberStatus>,
|
||||
nextStatus: MemberStatus,
|
||||
config: TeamModeConfig,
|
||||
sessionID: string,
|
||||
eventLabel: string,
|
||||
): Promise<void> {
|
||||
const runtimeState = await loadRuntimeState(runtimeMember.teamRunId, config)
|
||||
const currentEntry = runtimeState.members.find((member) => member.name === runtimeMember.memberName)
|
||||
if (currentEntry === undefined) return
|
||||
if (!allowedSources.has(currentEntry.status)) return
|
||||
|
||||
await transitionRuntimeState(runtimeState.teamRunId, (currentRuntimeState) => ({
|
||||
...currentRuntimeState,
|
||||
members: currentRuntimeState.members.map((member) => (
|
||||
member.name === runtimeMember.memberName
|
||||
? { ...member, status: nextStatus }
|
||||
: member
|
||||
)),
|
||||
}), config)
|
||||
|
||||
log(`team member ${eventLabel}`, {
|
||||
event: `team-mode-member-${eventLabel}`,
|
||||
teamRunId: runtimeState.teamRunId,
|
||||
teamName: runtimeState.teamName,
|
||||
memberName: runtimeMember.memberName,
|
||||
sessionID,
|
||||
previousStatus: currentEntry.status,
|
||||
nextStatus,
|
||||
})
|
||||
}
|
||||
|
||||
export function createTeamMemberStatusHandler(config: TeamModeConfig): HookImpl {
|
||||
return async ({ event }: HookInput): Promise<void> => {
|
||||
if (event.type === "session.idle") {
|
||||
const sessionID = getSessionIDFromIdleEvent(event.properties)
|
||||
if (!sessionID) return
|
||||
try {
|
||||
const runtimeMember = await findResolvedMemberSession(sessionID, config, "team member status handler")
|
||||
if (runtimeMember === null) return
|
||||
await transitionMemberStatus(runtimeMember, IDLE_TRANSITION_SOURCE_STATUSES, "idle", config, sessionID, "idled")
|
||||
} catch (error) {
|
||||
log("team member status handler failed on session.idle", {
|
||||
event: "team-mode-member-status-handler-error",
|
||||
sessionID,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionID = getSessionIDFromDeletedEvent(event.properties)
|
||||
if (!sessionID) return
|
||||
try {
|
||||
const runtimeMember = await findResolvedMemberSession(sessionID, config, "team member status handler")
|
||||
if (runtimeMember === null) return
|
||||
await transitionMemberStatus(runtimeMember, COMPLETED_TRANSITION_SOURCE_STATUSES, "completed", config, sessionID, "completed")
|
||||
} catch (error) {
|
||||
log("team member status handler failed on session.deleted", {
|
||||
event: "team-mode-member-status-handler-error",
|
||||
sessionID,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { TeamModeConfig } from "../../config/schema/team-mode"
|
||||
import { TeamModeConfigSchema } from "../../config/schema/team-mode"
|
||||
import {
|
||||
clearTeamSessionRegistry,
|
||||
registerTeamSession,
|
||||
} from "../../features/team-mode/team-session-registry"
|
||||
import type { RuntimeState } from "../../features/team-mode/types"
|
||||
import { saveRuntimeState } from "../../features/team-mode/team-state-store/store"
|
||||
import { createTeamToolGating } from "./hook"
|
||||
|
||||
function createConfig(overrides?: Partial<TeamModeConfig>, baseDir = "/tmp/team-mode"): TeamModeConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
tmux_visualization: false,
|
||||
max_parallel_members: 4,
|
||||
max_members: 8,
|
||||
max_messages_per_run: 10_000,
|
||||
max_wall_clock_minutes: 120,
|
||||
max_member_turns: 500,
|
||||
base_dir: baseDir,
|
||||
message_payload_max_bytes: 32_768,
|
||||
recipient_unread_max_bytes: 262_144,
|
||||
mailbox_poll_interval_ms: 3_000,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function createRuntimeState(): RuntimeState {
|
||||
return {
|
||||
version: 1,
|
||||
teamRunId: "11111111-1111-4111-8111-111111111111",
|
||||
teamName: "team-alpha",
|
||||
specSource: "project",
|
||||
createdAt: 1,
|
||||
status: "active",
|
||||
leadSessionId: "lead-session",
|
||||
members: [
|
||||
{ name: "m1", sessionId: "member-session-1", agentType: "general-purpose", status: "running", pendingInjectedMessageIds: [] },
|
||||
{ name: "m2", sessionId: "member-session-2", agentType: "general-purpose", status: "running", pendingInjectedMessageIds: [] },
|
||||
],
|
||||
shutdownRequests: [],
|
||||
bounds: { maxMembers: 8, maxParallelMembers: 4, maxMessagesPerRun: 10_000, maxWallClockMinutes: 120, maxMemberTurns: 500 },
|
||||
}
|
||||
}
|
||||
|
||||
async function seedTeams(baseDir: string, ...runtimeStates: RuntimeState[]): Promise<void> {
|
||||
const config = TeamModeConfigSchema.parse({ base_dir: baseDir, enabled: true })
|
||||
await Promise.all(runtimeStates.map(async (runtimeState) => {
|
||||
await mkdir(path.join(baseDir, "runtime", runtimeState.teamRunId), { recursive: true })
|
||||
await saveRuntimeState(runtimeState, config)
|
||||
}))
|
||||
}
|
||||
|
||||
async function runHook(tool: string, sessionID: string, args: Record<string, unknown>, config?: Partial<TeamModeConfig>, baseDir = "/tmp/team-mode"): Promise<void> {
|
||||
const hook = createTeamToolGating({ directory: baseDir } as PluginInput, createConfig(config, baseDir))
|
||||
await hook["tool.execute.before"]?.({ tool, sessionID, callID: "call-1" }, { args })
|
||||
}
|
||||
|
||||
describe("createTeamToolGating", () => {
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
temporaryDirectories.length = 0
|
||||
clearTeamSessionRegistry()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
clearTeamSessionRegistry()
|
||||
await Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => rm(directoryPath, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
test("allows a fresh session to call team_create", async () => {
|
||||
// given
|
||||
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
|
||||
temporaryDirectories.push(baseDir)
|
||||
await seedTeams(baseDir, createRuntimeState())
|
||||
|
||||
// when
|
||||
const result = runHook("team_create", "fresh-session", {}, undefined, baseDir)
|
||||
|
||||
// then
|
||||
await expect(result).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("allows team_list from a fresh session", async () => {
|
||||
// given
|
||||
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
|
||||
temporaryDirectories.push(baseDir)
|
||||
await seedTeams(baseDir, createRuntimeState())
|
||||
|
||||
// when
|
||||
const result = runHook("team_list", "fresh-session", {}, undefined, baseDir)
|
||||
|
||||
// then
|
||||
await expect(result).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("rejects team_create when the caller is already a team member", async () => {
|
||||
// given
|
||||
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
|
||||
temporaryDirectories.push(baseDir)
|
||||
await seedTeams(baseDir, createRuntimeState())
|
||||
|
||||
// when
|
||||
const result = runHook("team_create", "member-session-1", {}, undefined, baseDir)
|
||||
|
||||
// then
|
||||
await expect(result).rejects.toThrow("team_create denied: session is already a participant of team 11111111-1111-4111-8111-111111111111")
|
||||
})
|
||||
|
||||
test("allows the target member to self-approve shutdown", async () => {
|
||||
// given
|
||||
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
|
||||
temporaryDirectories.push(baseDir)
|
||||
await seedTeams(baseDir, createRuntimeState())
|
||||
|
||||
// when
|
||||
const result = runHook("team_approve_shutdown", "member-session-1", { teamRunId: "11111111-1111-4111-8111-111111111111", memberName: "m1" }, undefined, baseDir)
|
||||
|
||||
// then
|
||||
await expect(result).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("allows the lead to force-approve shutdown", async () => {
|
||||
// given
|
||||
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
|
||||
temporaryDirectories.push(baseDir)
|
||||
await seedTeams(baseDir, createRuntimeState())
|
||||
|
||||
// when
|
||||
const result = runHook("team_approve_shutdown", "lead-session", { teamRunId: "11111111-1111-4111-8111-111111111111", memberName: "m1" }, undefined, baseDir)
|
||||
|
||||
// then
|
||||
await expect(result).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("rejects a non-target member from approving shutdown", async () => {
|
||||
// given
|
||||
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
|
||||
temporaryDirectories.push(baseDir)
|
||||
await seedTeams(baseDir, createRuntimeState())
|
||||
|
||||
// when
|
||||
const result = runHook("team_approve_shutdown", "member-session-2", { teamRunId: "11111111-1111-4111-8111-111111111111", memberName: "m1" }, undefined, baseDir)
|
||||
|
||||
// then
|
||||
await expect(result).rejects.toThrow("team_approve_shutdown: caller must be target member or team lead")
|
||||
})
|
||||
|
||||
test("allows delegate-task for team members without a run-wide budget", async () => {
|
||||
// given
|
||||
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
|
||||
temporaryDirectories.push(baseDir)
|
||||
await seedTeams(baseDir, createRuntimeState())
|
||||
|
||||
// when
|
||||
const result = runHook("delegate-task", "member-session-1", {}, undefined, baseDir)
|
||||
|
||||
// then
|
||||
await expect(result).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("allows team_delete for the lead of the target team", async () => {
|
||||
// given
|
||||
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
|
||||
temporaryDirectories.push(baseDir)
|
||||
await seedTeams(baseDir, createRuntimeState())
|
||||
|
||||
// when
|
||||
const result = runHook("team_delete", "lead-session", { teamRunId: "11111111-1111-4111-8111-111111111111" }, undefined, baseDir)
|
||||
|
||||
// then
|
||||
await expect(result).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("no-ops for unrelated tools without querying team state", async () => {
|
||||
// given
|
||||
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
|
||||
temporaryDirectories.push(baseDir)
|
||||
await seedTeams(baseDir, createRuntimeState())
|
||||
|
||||
// when
|
||||
const result = runHook("write", "fresh-session", {}, undefined, baseDir)
|
||||
|
||||
// then
|
||||
await expect(result).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("allows team_send_message during the spawn race when runtime state lacks the member's sessionId but the registry already has it", async () => {
|
||||
// given
|
||||
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
|
||||
temporaryDirectories.push(baseDir)
|
||||
const staleRuntimeState: RuntimeState = {
|
||||
...createRuntimeState(),
|
||||
members: [
|
||||
{ name: "m1", agentType: "general-purpose", status: "pending", pendingInjectedMessageIds: [] },
|
||||
{ name: "m2", agentType: "general-purpose", status: "pending", pendingInjectedMessageIds: [] },
|
||||
],
|
||||
}
|
||||
await seedTeams(baseDir, staleRuntimeState)
|
||||
registerTeamSession("just-spawned-session", {
|
||||
teamRunId: "11111111-1111-4111-8111-111111111111",
|
||||
memberName: "m1",
|
||||
role: "member",
|
||||
})
|
||||
|
||||
// when
|
||||
const result = runHook("team_send_message", "just-spawned-session", { teamRunId: "11111111-1111-4111-8111-111111111111" }, undefined, baseDir)
|
||||
|
||||
// then
|
||||
await expect(result).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("allows team_send_message from a lead whose session is tracked only in the registry", async () => {
|
||||
// given
|
||||
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
|
||||
temporaryDirectories.push(baseDir)
|
||||
const staleRuntimeState: RuntimeState = {
|
||||
...createRuntimeState(),
|
||||
leadSessionId: undefined,
|
||||
members: [
|
||||
{ name: "lead", agentType: "leader", status: "pending", pendingInjectedMessageIds: [] },
|
||||
],
|
||||
}
|
||||
await seedTeams(baseDir, staleRuntimeState)
|
||||
registerTeamSession("caller-lead-session", {
|
||||
teamRunId: "11111111-1111-4111-8111-111111111111",
|
||||
memberName: "lead",
|
||||
role: "lead",
|
||||
})
|
||||
|
||||
// when
|
||||
const result = runHook("team_send_message", "caller-lead-session", { teamRunId: "11111111-1111-4111-8111-111111111111" }, undefined, baseDir)
|
||||
|
||||
// then
|
||||
await expect(result).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("rejects team_send_message when the session is not in the registry and not in runtime state", async () => {
|
||||
// given
|
||||
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
|
||||
temporaryDirectories.push(baseDir)
|
||||
await seedTeams(baseDir, createRuntimeState())
|
||||
|
||||
// when
|
||||
const result = runHook("team_send_message", "unknown-session", { teamRunId: "11111111-1111-4111-8111-111111111111" }, undefined, baseDir)
|
||||
|
||||
// then
|
||||
await expect(result).rejects.toThrow("team-mode tool team_send_message denied: not a participant of team 11111111-1111-4111-8111-111111111111")
|
||||
})
|
||||
|
||||
test("rejects team_send_message when the registry only has the caller for a different team than the requested teamRunId", async () => {
|
||||
// given
|
||||
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
|
||||
temporaryDirectories.push(baseDir)
|
||||
const emptyState: RuntimeState = { ...createRuntimeState(), members: [] }
|
||||
await seedTeams(baseDir, emptyState)
|
||||
registerTeamSession("cross-team-session", {
|
||||
teamRunId: "22222222-2222-4222-8222-222222222222",
|
||||
memberName: "other-team-member",
|
||||
role: "member",
|
||||
})
|
||||
|
||||
// when
|
||||
const result = runHook("team_send_message", "cross-team-session", { teamRunId: "11111111-1111-4111-8111-111111111111" }, undefined, baseDir)
|
||||
|
||||
// then
|
||||
await expect(result).rejects.toThrow("denied: not a participant of team 11111111-1111-4111-8111-111111111111")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
import type { TeamModeConfig } from "../../config/schema/team-mode"
|
||||
import { lookupTeamSession } from "../../features/team-mode/team-session-registry"
|
||||
import type { RuntimeState } from "../../features/team-mode/types"
|
||||
import {
|
||||
listActiveTeams,
|
||||
loadRuntimeState,
|
||||
} from "../../features/team-mode/team-state-store"
|
||||
|
||||
const ACTIVE_RUNTIME_STATUSES = new Set<RuntimeState["status"]>(["creating", "active", "shutdown_requested"])
|
||||
const UNIVERSAL_TOOL_NAMES = new Set([
|
||||
"team_send_message",
|
||||
"team_task_create",
|
||||
"team_task_list",
|
||||
"team_task_update",
|
||||
"team_task_get",
|
||||
"team_status",
|
||||
])
|
||||
|
||||
type TeamParticipant =
|
||||
| { role: "neither" }
|
||||
| { role: "lead"; teamRunId: string }
|
||||
| { role: "member"; teamRunId: string; memberName: string }
|
||||
|
||||
function getStringArg(args: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = args[key]
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
function resolveParticipantFromRegistry(sessionID: string): TeamParticipant | undefined {
|
||||
const entry = lookupTeamSession(sessionID)
|
||||
if (!entry) return undefined
|
||||
if (entry.role === "lead") {
|
||||
return { role: "lead", teamRunId: entry.teamRunId }
|
||||
}
|
||||
return { role: "member", teamRunId: entry.teamRunId, memberName: entry.memberName }
|
||||
}
|
||||
|
||||
async function resolveParticipant(sessionID: string, config: TeamModeConfig): Promise<TeamParticipant> {
|
||||
const fromRegistry = resolveParticipantFromRegistry(sessionID)
|
||||
if (fromRegistry) {
|
||||
return fromRegistry
|
||||
}
|
||||
|
||||
const activeTeams = await listActiveTeams(config)
|
||||
|
||||
for (const activeTeam of activeTeams) {
|
||||
const runtimeState = await loadRuntimeState(activeTeam.teamRunId, config)
|
||||
if (!ACTIVE_RUNTIME_STATUSES.has(runtimeState.status)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (runtimeState.leadSessionId === sessionID) {
|
||||
return { role: "lead", teamRunId: runtimeState.teamRunId }
|
||||
}
|
||||
|
||||
const matchedMember = runtimeState.members.find((member) => member.sessionId === sessionID)
|
||||
if (matchedMember) {
|
||||
return {
|
||||
role: "member",
|
||||
teamRunId: runtimeState.teamRunId,
|
||||
memberName: matchedMember.name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { role: "neither" }
|
||||
}
|
||||
|
||||
function isLeadOfTargetTeam(participant: TeamParticipant, teamRunId: string | undefined): boolean {
|
||||
return participant.role === "lead" && participant.teamRunId === teamRunId
|
||||
}
|
||||
|
||||
function isTargetMember(participant: TeamParticipant, teamRunId: string | undefined, memberName: string | undefined): boolean {
|
||||
return participant.role === "member"
|
||||
&& participant.teamRunId === teamRunId
|
||||
&& participant.memberName === memberName
|
||||
}
|
||||
|
||||
export function createTeamToolGating(_ctx: PluginInput, config: TeamModeConfig | undefined): Hooks {
|
||||
return {
|
||||
"tool.execute.before": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { args: Record<string, unknown> },
|
||||
): Promise<void> => {
|
||||
if (!config?.enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const toolName = input.tool
|
||||
if (!toolName.startsWith("team_") && toolName !== "delegate-task") {
|
||||
return
|
||||
}
|
||||
|
||||
const participant = await resolveParticipant(input.sessionID, config)
|
||||
|
||||
if (toolName === "delegate-task") {
|
||||
return
|
||||
}
|
||||
|
||||
if (toolName === "team_create") {
|
||||
if (participant.role !== "neither") {
|
||||
throw new Error(`team_create denied: session is already a participant of team ${participant.teamRunId}`)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const teamRunId = getStringArg(output.args, "teamRunId")
|
||||
const memberName = getStringArg(output.args, "memberName")
|
||||
|
||||
if (toolName === "team_delete" || toolName === "team_shutdown_request") {
|
||||
if (!isLeadOfTargetTeam(participant, teamRunId)) {
|
||||
throw new Error(`${toolName} is lead-only`)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (toolName === "team_approve_shutdown" || toolName === "team_reject_shutdown") {
|
||||
if (!isLeadOfTargetTeam(participant, teamRunId) && !isTargetMember(participant, teamRunId, memberName)) {
|
||||
throw new Error(`${toolName}: caller must be target member or team lead`)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (toolName === "team_list") {
|
||||
return
|
||||
}
|
||||
|
||||
if (UNIVERSAL_TOOL_NAMES.has(toolName)) {
|
||||
if (
|
||||
(participant.role === "lead" || participant.role === "member")
|
||||
&& participant.teamRunId === teamRunId
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
teamRunId === undefined
|
||||
? `team-mode tool ${toolName} requires teamRunId argument`
|
||||
: `team-mode tool ${toolName} denied: not a participant of team ${teamRunId}`,
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { createTeamToolGating } from "./hook"
|
||||
@@ -1,6 +1,6 @@
|
||||
# src/hooks/todo-continuation-enforcer/ — Boulder Continuation Mechanism
|
||||
|
||||
**Generated:** 2026-04-11
|
||||
**Generated:** 2026-05-08
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ export async function injectContinuation(args: {
|
||||
}
|
||||
|
||||
const hasRunningBgTasks = backgroundManager
|
||||
? backgroundManager.getTasksByParentSession(sessionID).some((task: { status: string }) => task.status === "running")
|
||||
? backgroundManager.getTasksByParentSession(sessionID).some((task: { status: string }) => task.status === "running" || task.status === "pending")
|
||||
: false
|
||||
|
||||
if (hasRunningBgTasks) {
|
||||
|
||||
@@ -13,6 +13,45 @@ import { handleSessionIdle } from "./idle-event"
|
||||
import { handleNonIdleEvent } from "./non-idle-events"
|
||||
import { isTokenLimitError } from "./token-limit-detection"
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return typeof value === "object" && value !== null ? value as Record<string, unknown> : undefined
|
||||
}
|
||||
|
||||
function getStringField(record: Record<string, unknown> | undefined, key: string): string | undefined {
|
||||
const value = record?.[key]
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function extractSessionErrorInfo(error: unknown): { name?: string; message?: string } | undefined {
|
||||
if (!error) return undefined
|
||||
if (typeof error === "string") return { message: error }
|
||||
if (error instanceof Error) return { name: error.name, message: error.message }
|
||||
|
||||
const root = asRecord(error)
|
||||
if (!root) return { message: String(error) }
|
||||
|
||||
const data = asRecord(root.data)
|
||||
const nestedError = asRecord(root.error)
|
||||
const dataError = asRecord(data?.error)
|
||||
|
||||
const name = getStringField(root, "name")
|
||||
?? getStringField(data, "name")
|
||||
?? getStringField(nestedError, "name")
|
||||
?? getStringField(dataError, "name")
|
||||
|
||||
const messageParts = [
|
||||
getStringField(root, "message"),
|
||||
getStringField(data, "message"),
|
||||
getStringField(nestedError, "message"),
|
||||
getStringField(dataError, "message"),
|
||||
getStringField(root, "code"),
|
||||
getStringField(nestedError, "code"),
|
||||
getStringField(dataError, "code"),
|
||||
].filter((message): message is string => typeof message === "string")
|
||||
|
||||
return { name, message: messageParts.join(" ") || undefined }
|
||||
}
|
||||
|
||||
export function createTodoContinuationHandler(args: {
|
||||
ctx: PluginInput
|
||||
sessionStateStore: SessionStateStore
|
||||
@@ -35,7 +74,8 @@ export function createTodoContinuationHandler(args: {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
const error = props?.error as { name?: string; message?: string } | undefined
|
||||
const error = extractSessionErrorInfo(props?.error)
|
||||
let shouldCancelCountdown = false
|
||||
if (error?.name === "MessageAbortedError" || error?.name === "AbortError") {
|
||||
const state = sessionStateStore.getState(sessionID)
|
||||
state.wasCancelled = true
|
||||
@@ -45,14 +85,18 @@ export function createTodoContinuationHandler(args: {
|
||||
state.awaitingPostInjectionProgressCheck = false
|
||||
state.stagnationCount = 0
|
||||
state.consecutiveFailures = 0
|
||||
shouldCancelCountdown = true
|
||||
log(`[${HOOK_NAME}] Abort detected via session.error`, { sessionID, errorName: error.name })
|
||||
} else if (isTokenLimitError(error)) {
|
||||
const state = sessionStateStore.getState(sessionID)
|
||||
state.tokenLimitDetected = true
|
||||
shouldCancelCountdown = true
|
||||
log(`[${HOOK_NAME}] Token limit error detected via session.error`, { sessionID, errorName: error?.name, errorMessage: error?.message })
|
||||
}
|
||||
|
||||
sessionStateStore.cancelCountdown(sessionID)
|
||||
if (shouldCancelCountdown) {
|
||||
sessionStateStore.cancelCountdown(sessionID)
|
||||
}
|
||||
log(`[${HOOK_NAME}] session.error`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export async function handleSessionIdle(args: {
|
||||
}
|
||||
|
||||
const hasRunningBgTasks = backgroundManager
|
||||
? backgroundManager.getTasksByParentSession(sessionID).some((task: { status: string }) => task.status === "running")
|
||||
? backgroundManager.getTasksByParentSession(sessionID).some((task: { status: string }) => task.status === "running" || task.status === "pending")
|
||||
: false
|
||||
|
||||
if (hasRunningBgTasks) {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { _resetForTesting, setMainSession } from "../../features/claude-code-session-state"
|
||||
import { createTodoContinuationEnforcer } from "."
|
||||
|
||||
type PromptCall = {
|
||||
sessionID: string
|
||||
text: string
|
||||
}
|
||||
|
||||
type PromptInput = {
|
||||
path: { id: string }
|
||||
body: { parts: Array<{ text: string }> }
|
||||
}
|
||||
|
||||
function wait(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function createPluginInput(promptCalls: PromptCall[]): Parameters<typeof createTodoContinuationEnforcer>[0] {
|
||||
return {
|
||||
directory: "/tmp/opencode-overload-continuation-test",
|
||||
client: {
|
||||
session: {
|
||||
todo: async () => ({
|
||||
data: [
|
||||
{ id: "1", content: "Keep working", status: "pending", priority: "high" },
|
||||
],
|
||||
}),
|
||||
messages: async () => ({ data: [] }),
|
||||
promptAsync: async (input: PromptInput) => {
|
||||
promptCalls.push({
|
||||
sessionID: input.path.id,
|
||||
text: input.body.parts[0]?.text ?? "",
|
||||
})
|
||||
return {}
|
||||
},
|
||||
},
|
||||
tui: {
|
||||
showToast: async () => ({}),
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof createTodoContinuationEnforcer>[0]
|
||||
}
|
||||
|
||||
describe("todo-continuation-enforcer OpenCode overload errors", () => {
|
||||
test(
|
||||
"#given countdown is armed #when OpenCode reports server_is_overloaded #then continuation still injects",
|
||||
async () => {
|
||||
// given
|
||||
const sessionID = "main-opencode-overload"
|
||||
const promptCalls: PromptCall[] = []
|
||||
_resetForTesting()
|
||||
setMainSession(sessionID)
|
||||
const hook = createTodoContinuationEnforcer(createPluginInput(promptCalls))
|
||||
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
// when
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
error: {
|
||||
type: "error",
|
||||
sequence_number: 2,
|
||||
error: {
|
||||
type: "service_unavailable_error",
|
||||
code: "server_is_overloaded",
|
||||
message: "Our servers are currently overloaded. Please try again later.",
|
||||
param: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await wait(2500)
|
||||
|
||||
// then
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
expect(promptCalls[0]?.sessionID).toBe(sessionID)
|
||||
expect(promptCalls[0]?.text).toContain("TODO CONTINUATION")
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
})
|
||||
@@ -40,9 +40,9 @@ function createBackgroundManager(tasks: BackgroundTask[]) {
|
||||
function createTask(overrides: Partial<BackgroundTask> = {}): BackgroundTask {
|
||||
return {
|
||||
id: "task-1",
|
||||
sessionID: "bg-1",
|
||||
parentSessionID: "main-1",
|
||||
parentMessageID: "msg-1",
|
||||
sessionId: "bg-1",
|
||||
parentSessionId: "main-1",
|
||||
parentMessageId: "msg-1",
|
||||
description: "unstable task",
|
||||
prompt: "run work",
|
||||
agent: "test-agent",
|
||||
@@ -63,6 +63,41 @@ describe("unstable-agent-babysitter hook", () => {
|
||||
_resetForTesting()
|
||||
})
|
||||
|
||||
test("settles idle before injecting a reminder", async () => {
|
||||
// #given
|
||||
setMainSession("main-1")
|
||||
const promptCalls: Array<{ input: unknown }> = []
|
||||
const ctx = createMockPluginInput({
|
||||
messagesBySession: {
|
||||
"main-1": [
|
||||
{ info: { agent: "sisyphus", model: { providerID: "openai", modelID: "gpt-4" } } },
|
||||
],
|
||||
"bg-1": [
|
||||
{ info: { role: "assistant" }, parts: [{ type: "thinking", thinking: "deep thought" }] },
|
||||
],
|
||||
},
|
||||
promptCalls,
|
||||
})
|
||||
const backgroundManager = createBackgroundManager([createTask()])
|
||||
const hook = createUnstableAgentBabysitterHook(ctx, {
|
||||
backgroundManager,
|
||||
config: { timeout_ms: 120000 },
|
||||
idleSettleMs: 50,
|
||||
})
|
||||
|
||||
// #when
|
||||
const startedAt = Date.now()
|
||||
const eventPromise = hook.event({ event: { type: "session.idle", properties: { sessionID: "main-1" } } })
|
||||
await Promise.resolve()
|
||||
|
||||
// #then
|
||||
expect(promptCalls.length).toBe(0)
|
||||
|
||||
await eventPromise
|
||||
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(45)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
test("fires reminder for hung gemini task", async () => {
|
||||
// #given
|
||||
setMainSession("main-1")
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
isUnstableTask,
|
||||
THINKING_SUMMARY_MAX_CHARS,
|
||||
} from "./task-message-analyzer"
|
||||
import { settleAfterSessionIdle } from "../shared/session-idle-settle"
|
||||
|
||||
const HOOK_NAME = "unstable-agent-babysitter"
|
||||
const DEFAULT_TIMEOUT_MS = 120000
|
||||
@@ -54,6 +55,7 @@ type BabysitterContext = {
|
||||
type BabysitterOptions = {
|
||||
backgroundManager: Pick<BackgroundManager, "getTasksByParentSession">
|
||||
config?: BabysittingConfig
|
||||
idleSettleMs?: number
|
||||
}
|
||||
|
||||
|
||||
@@ -212,6 +214,7 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option
|
||||
? { providerID: model.providerID, modelID: model.modelID }
|
||||
: undefined
|
||||
const launchVariant = model?.variant
|
||||
await settleAfterSessionIdle(options.idleSettleMs)
|
||||
|
||||
await ctx.client.session.promptAsync({
|
||||
path: { id: mainSessionID },
|
||||
|
||||
@@ -3,9 +3,7 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
import { existsSync, realpathSync } from "fs"
|
||||
import { basename, dirname, isAbsolute, join, normalize, relative, resolve } from "path"
|
||||
|
||||
import { log } from "../../shared"
|
||||
import { handleWriteExistingFileGuardToolExecuteBefore } from "./tool-execute-before-handler"
|
||||
import { evictLeastRecentlyUsedSession, touchSession, trimSessionReadSet } from "./session-read-permissions"
|
||||
|
||||
export type GuardArgs = {
|
||||
filePath?: string
|
||||
@@ -16,7 +14,11 @@ export type GuardArgs = {
|
||||
|
||||
const MAX_TRACKED_SESSIONS = 256
|
||||
export const MAX_TRACKED_PATHS_PER_SESSION = 1024
|
||||
const BLOCK_MESSAGE = "File already exists. Use edit tool instead."
|
||||
|
||||
type WriteExistingFileGuardOptions = {
|
||||
maxTrackedSessions?: number
|
||||
maxTrackedPathsPerSession?: number
|
||||
}
|
||||
|
||||
export function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
@@ -73,9 +75,11 @@ export function isOverwriteEnabled(value: boolean | string | undefined): boolean
|
||||
return false
|
||||
}
|
||||
|
||||
export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
|
||||
export function createWriteExistingFileGuardHook(ctx: PluginInput, options?: WriteExistingFileGuardOptions): Hooks {
|
||||
const readPermissionsBySession = new Map<string, Set<string>>()
|
||||
const sessionLastAccess = new Map<string, number>()
|
||||
const maxTrackedSessions = options?.maxTrackedSessions ?? MAX_TRACKED_SESSIONS
|
||||
const maxTrackedPathsPerSession = options?.maxTrackedPathsPerSession ?? MAX_TRACKED_PATHS_PER_SESSION
|
||||
let canonicalSessionRoot: string | undefined
|
||||
|
||||
function getCanonicalSessionRoot(): string {
|
||||
@@ -95,7 +99,8 @@ export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
|
||||
readPermissionsBySession,
|
||||
sessionLastAccess,
|
||||
getCanonicalSessionRoot,
|
||||
maxTrackedSessions: MAX_TRACKED_SESSIONS,
|
||||
maxTrackedSessions,
|
||||
maxTrackedPathsPerSession,
|
||||
})
|
||||
},
|
||||
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync
|
||||
import { tmpdir } from "node:os"
|
||||
import { dirname, join, resolve } from "node:path"
|
||||
|
||||
import { MAX_TRACKED_PATHS_PER_SESSION } from "./hook"
|
||||
import { createWriteExistingFileGuardHook } from "./index"
|
||||
|
||||
const BLOCK_MESSAGE = "File already exists. Use edit tool instead."
|
||||
@@ -56,7 +55,7 @@ describe("createWriteExistingFileGuardHook", () => {
|
||||
}
|
||||
|
||||
const emitSessionDeleted = async (sessionID: string): Promise<void> => {
|
||||
await hook.event?.({ event: { type: "session.deleted", properties: { info: { id: sessionID } } } })
|
||||
await hook.event?.({ event: { type: "session.deleted", properties: { info: { id: sessionID } } } } as never)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -432,6 +431,11 @@ describe("createWriteExistingFileGuardHook", () => {
|
||||
|
||||
test("#given session reads beyond path cap #when writing oldest and newest #then only newest is authorized", async () => {
|
||||
const sessionID = "ses_path_cap"
|
||||
const maxTrackedPathsPerSession = 4
|
||||
hook = createWriteExistingFileGuardHook(
|
||||
{ directory: tempDir } as never,
|
||||
{ maxTrackedPathsPerSession },
|
||||
)
|
||||
const oldestFile = createFile("path-cap/0.txt")
|
||||
let newestFile = oldestFile
|
||||
|
||||
@@ -441,7 +445,7 @@ describe("createWriteExistingFileGuardHook", () => {
|
||||
outputArgs: { filePath: oldestFile },
|
||||
})
|
||||
|
||||
for (let index = 1; index <= MAX_TRACKED_PATHS_PER_SESSION; index += 1) {
|
||||
for (let index = 1; index <= maxTrackedPathsPerSession; index += 1) {
|
||||
newestFile = createFile(`path-cap/${index}.txt`)
|
||||
await invoke({
|
||||
tool: "read",
|
||||
|
||||
@@ -5,37 +5,35 @@ import { join } from "node:path"
|
||||
|
||||
const realFs = await import("node:fs")
|
||||
|
||||
const existsSyncMock = mock(realFs.existsSync)
|
||||
const realpathNativeMock = mock(realFs.realpathSync.native)
|
||||
|
||||
mock.module("fs", () => ({
|
||||
...realFs,
|
||||
existsSync: existsSyncMock,
|
||||
realpathSync: {
|
||||
...realFs.realpathSync,
|
||||
native: realpathNativeMock,
|
||||
},
|
||||
}))
|
||||
|
||||
const { createWriteExistingFileGuardHook } = await import("./index")
|
||||
|
||||
describe("createWriteExistingFileGuardHook", () => {
|
||||
let tempDir = ""
|
||||
let existsSyncMock: ReturnType<typeof mock<typeof realFs.existsSync>>
|
||||
let realpathNativeMock: ReturnType<typeof mock<typeof realFs.realpathSync.native>>
|
||||
|
||||
beforeEach(() => {
|
||||
// given
|
||||
tempDir = mkdtempSync(join(tmpdir(), "write-existing-file-guard-lazy-"))
|
||||
mkdirSync(tempDir, { recursive: true })
|
||||
existsSyncMock.mockClear()
|
||||
realpathNativeMock.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test("#given hook factory #when created #then defers fs canonical path calls until first tool invocation", async () => {
|
||||
// given
|
||||
existsSyncMock = mock(realFs.existsSync)
|
||||
realpathNativeMock = mock(realFs.realpathSync.native)
|
||||
mock.module("fs", () => ({
|
||||
...realFs,
|
||||
existsSync: existsSyncMock,
|
||||
realpathSync: {
|
||||
...realFs.realpathSync,
|
||||
native: realpathNativeMock,
|
||||
},
|
||||
}))
|
||||
const { createWriteExistingFileGuardHook } = await import(`./hook?test=${crypto.randomUUID()}`)
|
||||
const existingFile = join(tempDir, "existing.txt")
|
||||
writeFileSync(existingFile, "content")
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ function registerReadPermission(params: {
|
||||
readPermissionsBySession: Map<string, Set<string>>
|
||||
sessionLastAccess: Map<string, number>
|
||||
maxTrackedSessions: number
|
||||
maxTrackedPathsPerSession: number
|
||||
}): void {
|
||||
const readSet = ensureSessionReadSet(params)
|
||||
if (readSet.has(params.canonicalPath)) {
|
||||
@@ -51,7 +52,7 @@ function registerReadPermission(params: {
|
||||
}
|
||||
|
||||
readSet.add(params.canonicalPath)
|
||||
trimSessionReadSet(readSet, MAX_TRACKED_PATHS_PER_SESSION)
|
||||
trimSessionReadSet(readSet, params.maxTrackedPathsPerSession)
|
||||
}
|
||||
|
||||
function consumeReadPermission(params: {
|
||||
@@ -92,8 +93,18 @@ export async function handleWriteExistingFileGuardToolExecuteBefore(params: {
|
||||
sessionLastAccess: Map<string, number>
|
||||
getCanonicalSessionRoot: () => string
|
||||
maxTrackedSessions: number
|
||||
maxTrackedPathsPerSession?: number
|
||||
}): Promise<void> {
|
||||
const { ctx, input, output, readPermissionsBySession, sessionLastAccess, getCanonicalSessionRoot, maxTrackedSessions } = params
|
||||
const {
|
||||
ctx,
|
||||
input,
|
||||
output,
|
||||
readPermissionsBySession,
|
||||
sessionLastAccess,
|
||||
getCanonicalSessionRoot,
|
||||
maxTrackedSessions,
|
||||
maxTrackedPathsPerSession = MAX_TRACKED_PATHS_PER_SESSION,
|
||||
} = params
|
||||
const toolName = input.tool?.toLowerCase()
|
||||
if (toolName !== "write" && toolName !== "read") {
|
||||
return
|
||||
@@ -124,6 +135,7 @@ export async function handleWriteExistingFileGuardToolExecuteBefore(params: {
|
||||
readPermissionsBySession,
|
||||
sessionLastAccess,
|
||||
maxTrackedSessions,
|
||||
maxTrackedPathsPerSession,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user