fix(hooks): remove gpt permission continuation hook
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
10
AGENTS.md
10
AGENTS.md
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
|
|
||||||
OpenCode plugin (npm: `oh-my-opencode`) that extends Claude Code (OpenCode fork) with multi-agent orchestration, 46 lifecycle hooks, 26 tools, skill/command/MCP systems, and Claude Code compatibility. 1268 TypeScript files, 160k LOC.
|
OpenCode plugin (npm: `oh-my-opencode`) that extends Claude Code (OpenCode fork) with multi-agent orchestration, 45 lifecycle hooks, 26 tools, skill/command/MCP systems, and Claude Code compatibility. 1268 TypeScript files, 160k LOC.
|
||||||
|
|
||||||
## STRUCTURE
|
## STRUCTURE
|
||||||
|
|
||||||
@@ -14,14 +14,14 @@ oh-my-opencode/
|
|||||||
│ ├── index.ts # Plugin entry: loadConfig → createManagers → createTools → createHooks → createPluginInterface
|
│ ├── index.ts # Plugin entry: loadConfig → createManagers → createTools → createHooks → createPluginInterface
|
||||||
│ ├── plugin-config.ts # JSONC multi-level config: user → project → defaults (Zod v4)
|
│ ├── plugin-config.ts # JSONC multi-level config: user → project → defaults (Zod v4)
|
||||||
│ ├── agents/ # 11 agents (Sisyphus, Hephaestus, Oracle, Librarian, Explore, Atlas, Prometheus, Metis, Momus, Multimodal-Looker, Sisyphus-Junior)
|
│ ├── agents/ # 11 agents (Sisyphus, Hephaestus, Oracle, Librarian, Explore, Atlas, Prometheus, Metis, Momus, Multimodal-Looker, Sisyphus-Junior)
|
||||||
│ ├── hooks/ # 46 hooks across 45 directories + 11 standalone files
|
│ ├── hooks/ # 45 hooks across 44 directories + 11 standalone files
|
||||||
│ ├── tools/ # 26 tools across 15 directories
|
│ ├── tools/ # 26 tools across 15 directories
|
||||||
│ ├── features/ # 19 feature modules (background-agent, skill-loader, tmux, MCP-OAuth, etc.)
|
│ ├── features/ # 19 feature modules (background-agent, skill-loader, tmux, MCP-OAuth, etc.)
|
||||||
│ ├── shared/ # 95+ utility files in 13 categories
|
│ ├── shared/ # 95+ utility files in 13 categories
|
||||||
│ ├── config/ # Zod v4 schema system (24 files)
|
│ ├── config/ # Zod v4 schema system (24 files)
|
||||||
│ ├── cli/ # CLI: install, run, doctor, mcp-oauth (Commander.js)
|
│ ├── cli/ # CLI: install, run, doctor, mcp-oauth (Commander.js)
|
||||||
│ ├── mcp/ # 3 built-in remote MCPs (websearch, context7, grep_app)
|
│ ├── mcp/ # 3 built-in remote MCPs (websearch, context7, grep_app)
|
||||||
│ ├── plugin/ # 8 OpenCode hook handlers + 46 hook composition
|
│ ├── plugin/ # 8 OpenCode hook handlers + 45 hook composition
|
||||||
│ └── plugin-handlers/ # 6-phase config loading pipeline
|
│ └── plugin-handlers/ # 6-phase config loading pipeline
|
||||||
├── packages/ # Monorepo: cli-runner, 12 platform binaries
|
├── packages/ # Monorepo: cli-runner, 12 platform binaries
|
||||||
└── local-ignore/ # Dev-only test fixtures
|
└── local-ignore/ # Dev-only test fixtures
|
||||||
@@ -34,7 +34,7 @@ OhMyOpenCodePlugin(ctx)
|
|||||||
├─→ loadPluginConfig() # JSONC parse → project/user merge → Zod validate → migrate
|
├─→ loadPluginConfig() # JSONC parse → project/user merge → Zod validate → migrate
|
||||||
├─→ createManagers() # TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler
|
├─→ createManagers() # TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler
|
||||||
├─→ createTools() # SkillContext + AvailableCategories + ToolRegistry (26 tools)
|
├─→ createTools() # SkillContext + AvailableCategories + ToolRegistry (26 tools)
|
||||||
├─→ createHooks() # 3-tier: Core(37) + Continuation(7) + Skill(2) = 46 hooks
|
├─→ createHooks() # 3-tier: Core(37) + Continuation(6) + Skill(2) = 45 hooks
|
||||||
└─→ createPluginInterface() # 8 OpenCode hook handlers → PluginInterface
|
└─→ createPluginInterface() # 8 OpenCode hook handlers → PluginInterface
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ Fields: agents (14 overridable, 21 fields each), categories (8 built-in + custom
|
|||||||
- **Test pattern**: Bun test (`bun:test`), co-located `*.test.ts`, given/when/then style (nested describe with `#given`/`#when`/`#then` prefixes)
|
- **Test pattern**: Bun test (`bun:test`), co-located `*.test.ts`, given/when/then style (nested describe with `#given`/`#when`/`#then` prefixes)
|
||||||
- **CI test split**: mock-heavy tests run in isolation (separate `bun test` processes), rest in batch
|
- **CI test split**: mock-heavy tests run in isolation (separate `bun test` processes), rest in batch
|
||||||
- **Factory pattern**: `createXXX()` for all tools, hooks, agents
|
- **Factory pattern**: `createXXX()` for all tools, hooks, agents
|
||||||
- **Hook tiers**: Session (23) → Tool-Guard (10) → Transform (4) → Continuation (7) → Skill (2)
|
- **Hook tiers**: Session (23) → Tool-Guard (10) → Transform (4) → Continuation (6) → Skill (2)
|
||||||
- **Agent modes**: `primary` (respects UI model) vs `subagent` (own fallback chain) vs `all`
|
- **Agent modes**: `primary` (respects UI model) vs `subagent` (own fallback chain) vs `all`
|
||||||
- **Model resolution**: 4-step: override → category-default → provider-fallback → system-default
|
- **Model resolution**: 4-step: override → category-default → provider-fallback → system-default
|
||||||
- **Config format**: JSONC with comments, Zod v4 validation, snake_case keys
|
- **Config format**: JSONC with comments, Zod v4 validation, snake_case keys
|
||||||
|
|||||||
@@ -304,7 +304,7 @@ See full [Features Documentation](docs/reference/features.md).
|
|||||||
- **Claude Code Compatibility**: Full hook system, commands, skills, agents, MCPs
|
- **Claude Code Compatibility**: Full hook system, commands, skills, agents, MCPs
|
||||||
- **Built-in MCPs**: websearch (Exa), context7 (docs), grep_app (GitHub search)
|
- **Built-in MCPs**: websearch (Exa), context7 (docs), grep_app (GitHub search)
|
||||||
- **Session Tools**: List, read, search, and analyze session history
|
- **Session Tools**: List, read, search, and analyze session history
|
||||||
- **Productivity Features**: Ralph Loop, Todo Enforcer, GPT permission-tail continuation, Comment Checker, Think Mode, and more
|
- **Productivity Features**: Ralph Loop, Todo Enforcer, Comment Checker, Think Mode, and more
|
||||||
- **Model Setup**: Agent-model matching is built into the [Installation Guide](docs/guide/installation.md#step-5-understand-your-model-setup)
|
- **Model Setup**: Agent-model matching is built into the [Installation Guide](docs/guide/installation.md#step-5-understand-your-model-setup)
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
@@ -321,7 +321,7 @@ See [Configuration Documentation](docs/reference/configuration.md).
|
|||||||
- **Sisyphus Agent**: Main orchestrator with Prometheus (Planner) and Metis (Plan Consultant)
|
- **Sisyphus Agent**: Main orchestrator with Prometheus (Planner) and Metis (Plan Consultant)
|
||||||
- **Background Tasks**: Configure concurrency limits per provider/model
|
- **Background Tasks**: Configure concurrency limits per provider/model
|
||||||
- **Categories**: Domain-specific task delegation (`visual`, `business-logic`, custom)
|
- **Categories**: Domain-specific task delegation (`visual`, `business-logic`, custom)
|
||||||
- **Hooks**: 25+ built-in hooks, including `gpt-permission-continuation`, all configurable via `disabled_hooks`
|
- **Hooks**: 25+ built-in hooks, all configurable via `disabled_hooks`
|
||||||
- **MCPs**: Built-in websearch (Exa), context7 (docs), grep_app (GitHub search)
|
- **MCPs**: Built-in websearch (Exa), context7 (docs), grep_app (GitHub search)
|
||||||
- **LSP**: Full LSP support with refactoring tools
|
- **LSP**: Full LSP support with refactoring tools
|
||||||
- **Experimental**: Aggressive truncation, auto-resume, and more
|
- **Experimental**: Aggressive truncation, auto-resume, and more
|
||||||
|
|||||||
@@ -418,15 +418,14 @@ Disable built-in skills: `{ "disabled_skills": ["playwright"] }`
|
|||||||
Disable built-in hooks via `disabled_hooks`:
|
Disable built-in hooks via `disabled_hooks`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "disabled_hooks": ["comment-checker", "gpt-permission-continuation"] }
|
{ "disabled_hooks": ["comment-checker"] }
|
||||||
```
|
```
|
||||||
|
|
||||||
Available hooks: `gpt-permission-continuation`, `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`, `preemptive-compaction`, `auto-slash-command`, `sisyphus-junior-notepad`, `no-sisyphus-gpt`, `start-work`, `runtime-fallback`
|
Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`, `preemptive-compaction`, `auto-slash-command`, `sisyphus-junior-notepad`, `no-sisyphus-gpt`, `start-work`, `runtime-fallback`
|
||||||
|
|
||||||
**Notes:**
|
**Notes:**
|
||||||
|
|
||||||
- `directory-agents-injector` — auto-disabled on OpenCode 1.1.37+ (native AGENTS.md support)
|
- `directory-agents-injector` — auto-disabled on OpenCode 1.1.37+ (native AGENTS.md support)
|
||||||
- `gpt-permission-continuation` — resumes GPT sessions only when the last assistant reply ends with a permission-seeking tail like `If you want, ...`. Disable it if you prefer GPT sessions to wait for explicit user follow-up.
|
|
||||||
- `no-sisyphus-gpt` — **do not disable**. It blocks incompatible GPT models for Sisyphus while allowing the dedicated GPT-5.4 prompt path.
|
- `no-sisyphus-gpt` — **do not disable**. It blocks incompatible GPT models for Sisyphus while allowing the dedicated GPT-5.4 prompt path.
|
||||||
- `startup-toast` is a sub-feature of `auto-update-checker`. Disable just the toast by adding `startup-toast` to `disabled_hooks`.
|
- `startup-toast` is a sub-feature of `auto-update-checker`. Disable just the toast by adding `startup-toast` to `disabled_hooks`.
|
||||||
|
|
||||||
|
|||||||
@@ -680,7 +680,6 @@ Hooks intercept and modify behavior at key points in the agent lifecycle across
|
|||||||
| **ralph-loop** | Event + Message | Manages self-referential loop continuation. |
|
| **ralph-loop** | Event + Message | Manages self-referential loop continuation. |
|
||||||
| **start-work** | Message | Handles /start-work command execution. |
|
| **start-work** | Message | Handles /start-work command execution. |
|
||||||
| **auto-slash-command** | Message | Automatically executes slash commands from prompts. |
|
| **auto-slash-command** | Message | Automatically executes slash commands from prompts. |
|
||||||
| **gpt-permission-continuation** | Event | Auto-continues GPT sessions when the final assistant reply ends with a permission-seeking tail such as `If you want, ...`. |
|
|
||||||
| **stop-continuation-guard** | Event + Message | Guards the stop-continuation mechanism. |
|
| **stop-continuation-guard** | Event + Message | Guards the stop-continuation mechanism. |
|
||||||
| **category-skill-reminder** | Event + PostToolUse | Reminds agents about available category skills for delegation. |
|
| **category-skill-reminder** | Event + PostToolUse | Reminds agents about available category skills for delegation. |
|
||||||
| **anthropic-effort** | Params | Adjusts Anthropic API effort level based on context. |
|
| **anthropic-effort** | Params | Adjusts Anthropic API effort level based on context. |
|
||||||
@@ -735,7 +734,6 @@ Hooks intercept and modify behavior at key points in the agent lifecycle across
|
|||||||
|
|
||||||
| Hook | Event | Description |
|
| Hook | Event | Description |
|
||||||
| ------------------------------ | ----- | ---------------------------------------------------------- |
|
| ------------------------------ | ----- | ---------------------------------------------------------- |
|
||||||
| **gpt-permission-continuation** | Event | Continues GPT replies that end in a permission-seeking tail. |
|
|
||||||
| **todo-continuation-enforcer** | Event | Enforces todo completion — yanks idle agents back to work. |
|
| **todo-continuation-enforcer** | Event | Enforces todo completion — yanks idle agents back to work. |
|
||||||
| **compaction-todo-preserver** | Event | Preserves todo state during session compaction. |
|
| **compaction-todo-preserver** | Event | Preserves todo state during session compaction. |
|
||||||
| **unstable-agent-babysitter** | Event | Handles unstable agent behavior with recovery strategies. |
|
| **unstable-agent-babysitter** | Event | Handles unstable agent behavior with recovery strategies. |
|
||||||
@@ -787,12 +785,10 @@ Disable specific hooks in config:
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"disabled_hooks": ["comment-checker", "gpt-permission-continuation"]
|
"disabled_hooks": ["comment-checker"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `gpt-permission-continuation` when you want GPT sessions to stop at permission-seeking endings instead of auto-resuming.
|
|
||||||
|
|
||||||
## MCPs
|
## MCPs
|
||||||
|
|
||||||
### Built-in MCPs
|
### Built-in MCPs
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Entry point `index.ts` orchestrates 5-step initialization: loadConfig → create
|
|||||||
| `plugin-config.ts` | JSONC parse, multi-level merge, Zod v4 validation |
|
| `plugin-config.ts` | JSONC parse, multi-level merge, Zod v4 validation |
|
||||||
| `create-managers.ts` | TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler |
|
| `create-managers.ts` | TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler |
|
||||||
| `create-tools.ts` | SkillContext + AvailableCategories + ToolRegistry (26 tools) |
|
| `create-tools.ts` | SkillContext + AvailableCategories + ToolRegistry (26 tools) |
|
||||||
| `create-hooks.ts` | 3-tier: Core(37) + Continuation(7) + Skill(2) = 46 hooks |
|
| `create-hooks.ts` | 3-tier: Core(37) + Continuation(6) + Skill(2) = 45 hooks |
|
||||||
| `plugin-interface.ts` | 8 OpenCode hook handlers: config, tool, chat.message, chat.params, chat.headers, event, tool.execute.before, tool.execute.after |
|
| `plugin-interface.ts` | 8 OpenCode hook handlers: config, tool, chat.message, chat.params, chat.headers, event, tool.execute.before, tool.execute.after |
|
||||||
|
|
||||||
## CONFIG LOADING
|
## CONFIG LOADING
|
||||||
@@ -36,6 +36,6 @@ createHooks()
|
|||||||
│ ├─ createSessionHooks() # 23: contextWindowMonitor, thinkMode, ralphLoop, modelFallback, runtimeFallback, noSisyphusGpt, noHephaestusNonGpt, anthropicEffort, intentGate...
|
│ ├─ createSessionHooks() # 23: contextWindowMonitor, thinkMode, ralphLoop, modelFallback, runtimeFallback, noSisyphusGpt, noHephaestusNonGpt, anthropicEffort, intentGate...
|
||||||
│ ├─ createToolGuardHooks() # 10: commentChecker, rulesInjector, writeExistingFileGuard, jsonErrorRecovery, hashlineReadEnhancer...
|
│ ├─ createToolGuardHooks() # 10: commentChecker, rulesInjector, writeExistingFileGuard, jsonErrorRecovery, hashlineReadEnhancer...
|
||||||
│ └─ createTransformHooks() # 4: claudeCodeHooks, keywordDetector, contextInjector, thinkingBlockValidator
|
│ └─ createTransformHooks() # 4: claudeCodeHooks, keywordDetector, contextInjector, thinkingBlockValidator
|
||||||
├─→ createContinuationHooks() # 7: todoContinuationEnforcer, atlas, stopContinuationGuard, ralphLoopActivator...
|
├─→ createContinuationHooks() # 6: todoContinuationEnforcer, atlas, stopContinuationGuard, ralphLoopActivator...
|
||||||
└─→ createSkillHooks() # 2: categorySkillReminder, autoSlashCommand
|
└─→ createSkillHooks() # 2: categorySkillReminder, autoSlashCommand
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ config/schema/
|
|||||||
├── agent-names.ts # BuiltinAgentNameSchema (11), OverridableAgentNameSchema (14)
|
├── agent-names.ts # BuiltinAgentNameSchema (11), OverridableAgentNameSchema (14)
|
||||||
├── agent-overrides.ts # AgentOverrideConfigSchema (21 fields per agent)
|
├── agent-overrides.ts # AgentOverrideConfigSchema (21 fields per agent)
|
||||||
├── categories.ts # 8 built-in + custom categories
|
├── categories.ts # 8 built-in + custom categories
|
||||||
├── hooks.ts # HookNameSchema (46 hooks)
|
├── hooks.ts # HookNameSchema (45 hooks)
|
||||||
├── skills.ts # SkillsConfigSchema (sources, paths, recursive)
|
├── skills.ts # SkillsConfigSchema (sources, paths, recursive)
|
||||||
├── commands.ts # BuiltinCommandNameSchema
|
├── commands.ts # BuiltinCommandNameSchema
|
||||||
├── experimental.ts # Feature flags (plugin_load_timeout_ms min 1000)
|
├── experimental.ts # Feature flags (plugin_load_timeout_ms min 1000)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
export const HookNameSchema = z.enum([
|
export const HookNameSchema = z.enum([
|
||||||
"gpt-permission-continuation",
|
|
||||||
"todo-continuation-enforcer",
|
"todo-continuation-enforcer",
|
||||||
"context-window-monitor",
|
"context-window-monitor",
|
||||||
"session-recovery",
|
"session-recovery",
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# src/hooks/ — 46 Lifecycle Hooks
|
# src/hooks/ — 45 Lifecycle Hooks
|
||||||
|
|
||||||
**Generated:** 2026-03-06
|
**Generated:** 2026-03-06
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
|
|
||||||
46 hooks across 45 directories + 11 standalone files. Three-tier composition: Core(37) + Continuation(7) + Skill(2). All hooks follow `createXXXHook(deps) → HookFunction` factory pattern.
|
45 hooks across 44 directories + 11 standalone files. Three-tier composition: Core(37) + Continuation(6) + Skill(2). All hooks follow `createXXXHook(deps) → HookFunction` factory pattern.
|
||||||
|
|
||||||
## HOOK TIERS
|
## HOOK TIERS
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ hooks/
|
|||||||
| contextInjectorMessagesTransform | messages.transform | Inject AGENTS.md/README.md into context |
|
| contextInjectorMessagesTransform | messages.transform | Inject AGENTS.md/README.md into context |
|
||||||
| thinkingBlockValidator | messages.transform | Validate thinking block structure |
|
| thinkingBlockValidator | messages.transform | Validate thinking block structure |
|
||||||
|
|
||||||
### Tier 4: Continuation Hooks (7) — `create-continuation-hooks.ts`
|
### Tier 4: Continuation Hooks (6) — `create-continuation-hooks.ts`
|
||||||
|
|
||||||
| Hook | Event | Purpose |
|
| Hook | Event | Purpose |
|
||||||
|------|-------|---------|
|
|------|-------|---------|
|
||||||
|
|||||||
@@ -88,7 +88,6 @@ function scheduleRetry(input: {
|
|||||||
const currentProgress = getPlanProgress(currentBoulder.active_plan)
|
const currentProgress = getPlanProgress(currentBoulder.active_plan)
|
||||||
if (currentProgress.isComplete) return
|
if (currentProgress.isComplete) return
|
||||||
if (options?.isContinuationStopped?.(sessionID)) return
|
if (options?.isContinuationStopped?.(sessionID)) return
|
||||||
if (options?.shouldSkipContinuation?.(sessionID)) return
|
|
||||||
if (hasRunningBackgroundTasks(sessionID, options)) return
|
if (hasRunningBackgroundTasks(sessionID, options)) return
|
||||||
|
|
||||||
await injectContinuation({
|
await injectContinuation({
|
||||||
@@ -177,11 +176,6 @@ export async function handleAtlasSessionIdle(input: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options?.shouldSkipContinuation?.(sessionID)) {
|
|
||||||
log(`[${HOOK_NAME}] Skipped: another continuation hook already injected`, { sessionID })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sessionState.lastContinuationInjectedAt && now - sessionState.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS) {
|
if (sessionState.lastContinuationInjectedAt && now - sessionState.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS) {
|
||||||
scheduleRetry({ ctx, sessionID, sessionState, options })
|
scheduleRetry({ ctx, sessionID, sessionState, options })
|
||||||
log(`[${HOOK_NAME}] Skipped: continuation cooldown active`, {
|
log(`[${HOOK_NAME}] Skipped: continuation cooldown active`, {
|
||||||
|
|||||||
@@ -1464,37 +1464,6 @@ session_id: ses_untrusted_999
|
|||||||
expect(mockInput._promptMock).not.toHaveBeenCalled()
|
expect(mockInput._promptMock).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should skip when another continuation hook already injected", 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 = createAtlasHook(mockInput, {
|
|
||||||
directory: TEST_DIR,
|
|
||||||
shouldSkipContinuation: (sessionID: string) => sessionID === MAIN_SESSION_ID,
|
|
||||||
})
|
|
||||||
|
|
||||||
// when
|
|
||||||
await hook.handler({
|
|
||||||
event: {
|
|
||||||
type: "session.idle",
|
|
||||||
properties: { sessionID: MAIN_SESSION_ID },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// then - should not call prompt because another continuation already handled it
|
|
||||||
expect(mockInput._promptMock).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("should clear abort state on message.updated", async () => {
|
test("should clear abort state on message.updated", async () => {
|
||||||
// given - boulder with incomplete plan
|
// given - boulder with incomplete plan
|
||||||
const planPath = join(TEST_DIR, "test-plan.md")
|
const planPath = join(TEST_DIR, "test-plan.md")
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ export interface AtlasHookOptions {
|
|||||||
directory: string
|
directory: string
|
||||||
backgroundManager?: BackgroundManager
|
backgroundManager?: BackgroundManager
|
||||||
isContinuationStopped?: (sessionID: string) => boolean
|
isContinuationStopped?: (sessionID: string) => boolean
|
||||||
shouldSkipContinuation?: (sessionID: string) => boolean
|
|
||||||
agentOverrides?: AgentOverrides
|
agentOverrides?: AgentOverrides
|
||||||
/** Enable auto-commit after each atomic task completion (default: true) */
|
/** Enable auto-commit after each atomic task completion (default: true) */
|
||||||
autoCommit?: boolean
|
autoCommit?: boolean
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
type TextPart = {
|
|
||||||
type?: string
|
|
||||||
text?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type MessageInfo = {
|
|
||||||
id?: string
|
|
||||||
role?: string
|
|
||||||
error?: unknown
|
|
||||||
model?: {
|
|
||||||
providerID?: string
|
|
||||||
modelID?: string
|
|
||||||
}
|
|
||||||
providerID?: string
|
|
||||||
modelID?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SessionMessage = {
|
|
||||||
info?: MessageInfo
|
|
||||||
parts?: TextPart[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getLastAssistantMessage(messages: SessionMessage[]): SessionMessage | null {
|
|
||||||
for (let index = messages.length - 1; index >= 0; index--) {
|
|
||||||
if (messages[index].info?.role === "assistant") {
|
|
||||||
return messages[index]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractAssistantText(message: SessionMessage): string {
|
|
||||||
return (message.parts ?? [])
|
|
||||||
.filter((part) => part.type === "text" && typeof part.text === "string")
|
|
||||||
.map((part) => part.text?.trim() ?? "")
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isGptAssistantMessage(message: SessionMessage): boolean {
|
|
||||||
const modelID = message.info?.model?.modelID ?? message.info?.modelID
|
|
||||||
return typeof modelID === "string" && modelID.toLowerCase().includes("gpt")
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
export const HOOK_NAME = "gpt-permission-continuation"
|
|
||||||
export const CONTINUATION_PROMPT = "continue"
|
|
||||||
export const MAX_CONSECUTIVE_AUTO_CONTINUES = 3
|
|
||||||
|
|
||||||
export const DEFAULT_STALL_PATTERNS = [
|
|
||||||
"if you want",
|
|
||||||
"would you like",
|
|
||||||
"shall i",
|
|
||||||
"do you want me to",
|
|
||||||
"let me know if",
|
|
||||||
] as const
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { DEFAULT_STALL_PATTERNS } from "./constants"
|
|
||||||
|
|
||||||
function getTrailingSegment(text: string): string {
|
|
||||||
const normalized = text.trim().replace(/\s+/g, " ")
|
|
||||||
if (!normalized) return ""
|
|
||||||
|
|
||||||
const sentenceParts = normalized.split(/(?<=[.!?])\s+/)
|
|
||||||
return sentenceParts[sentenceParts.length - 1]?.trim().toLowerCase() ?? ""
|
|
||||||
}
|
|
||||||
|
|
||||||
export function detectStallPattern(
|
|
||||||
text: string,
|
|
||||||
patterns: readonly string[] = DEFAULT_STALL_PATTERNS,
|
|
||||||
): boolean {
|
|
||||||
if (!text.trim()) return false
|
|
||||||
|
|
||||||
const tail = text.slice(-800)
|
|
||||||
const lines = tail.split("\n").map((line) => line.trim()).filter(Boolean)
|
|
||||||
const hotZone = lines.slice(-3).join(" ")
|
|
||||||
const trailingSegment = getTrailingSegment(hotZone)
|
|
||||||
|
|
||||||
return patterns.some((pattern) => trailingSegment.startsWith(pattern.toLowerCase()))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractPermissionPhrase(text: string): string | null {
|
|
||||||
const tail = text.slice(-800)
|
|
||||||
const lines = tail.split("\n").map((line) => line.trim()).filter(Boolean)
|
|
||||||
const hotZone = lines.slice(-3).join(" ")
|
|
||||||
const sentenceParts = hotZone.trim().replace(/\s+/g, " ").split(/(?<=[.!?])\s+/)
|
|
||||||
const trailingSegment = sentenceParts[sentenceParts.length - 1]?.trim().toLowerCase() ?? ""
|
|
||||||
return trailingSegment || null
|
|
||||||
}
|
|
||||||
@@ -1,384 +0,0 @@
|
|||||||
/// <reference path="../../../bun-test.d.ts" />
|
|
||||||
|
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
|
||||||
import { afterEach, describe, expect, it as test } from "bun:test"
|
|
||||||
|
|
||||||
import { subagentSessions, _resetForTesting } from "../../features/claude-code-session-state"
|
|
||||||
import { createGptPermissionContinuationHook } from "."
|
|
||||||
|
|
||||||
type SessionMessage = {
|
|
||||||
info: {
|
|
||||||
id: string
|
|
||||||
role: "user" | "assistant"
|
|
||||||
model?: {
|
|
||||||
providerID?: string
|
|
||||||
modelID?: string
|
|
||||||
}
|
|
||||||
modelID?: string
|
|
||||||
}
|
|
||||||
parts?: Array<{ type: string; text?: string }>
|
|
||||||
}
|
|
||||||
|
|
||||||
type GptPermissionContext = Parameters<typeof createGptPermissionContinuationHook>[0]
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === "object" && value !== null
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractPromptText(input: unknown): string {
|
|
||||||
if (!isRecord(input)) return ""
|
|
||||||
|
|
||||||
const body = input.body
|
|
||||||
if (!isRecord(body)) return ""
|
|
||||||
|
|
||||||
const parts = body.parts
|
|
||||||
if (!Array.isArray(parts)) return ""
|
|
||||||
|
|
||||||
const firstPart = parts[0]
|
|
||||||
if (!isRecord(firstPart)) return ""
|
|
||||||
|
|
||||||
return typeof firstPart.text === "string" ? firstPart.text : ""
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMockPluginInput(messages: SessionMessage[]): {
|
|
||||||
ctx: GptPermissionContext
|
|
||||||
promptCalls: string[]
|
|
||||||
} {
|
|
||||||
const promptCalls: string[] = []
|
|
||||||
const client = createOpencodeClient({ directory: "/tmp/test" })
|
|
||||||
const shell = Object.assign(
|
|
||||||
() => {
|
|
||||||
throw new Error("$ is not used in this test")
|
|
||||||
},
|
|
||||||
{
|
|
||||||
braces: () => [],
|
|
||||||
escape: (input: string) => input,
|
|
||||||
env() {
|
|
||||||
return shell
|
|
||||||
},
|
|
||||||
cwd() {
|
|
||||||
return shell
|
|
||||||
},
|
|
||||||
nothrow() {
|
|
||||||
return shell
|
|
||||||
},
|
|
||||||
throws() {
|
|
||||||
return shell
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
const request = new Request("http://localhost")
|
|
||||||
const response = new Response()
|
|
||||||
|
|
||||||
Reflect.set(client.session, "messages", async () => ({ data: messages, error: undefined, request, response }))
|
|
||||||
Reflect.set(client.session, "prompt", async (input: unknown) => {
|
|
||||||
promptCalls.push(extractPromptText(input))
|
|
||||||
return { data: undefined, error: undefined, request, response }
|
|
||||||
})
|
|
||||||
Reflect.set(client.session, "promptAsync", async (input: unknown) => {
|
|
||||||
promptCalls.push(extractPromptText(input))
|
|
||||||
return { data: undefined, error: undefined, request, response }
|
|
||||||
})
|
|
||||||
|
|
||||||
const ctx: GptPermissionContext = {
|
|
||||||
client,
|
|
||||||
project: {
|
|
||||||
id: "test-project",
|
|
||||||
worktree: "/tmp/test",
|
|
||||||
time: { created: Date.now() },
|
|
||||||
},
|
|
||||||
directory: "/tmp/test",
|
|
||||||
worktree: "/tmp/test",
|
|
||||||
serverUrl: new URL("http://localhost"),
|
|
||||||
$: shell,
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ctx, promptCalls }
|
|
||||||
}
|
|
||||||
|
|
||||||
function createAssistantMessage(id: string, text: string): SessionMessage {
|
|
||||||
return {
|
|
||||||
info: { id, role: "assistant", modelID: "gpt-5.4" },
|
|
||||||
parts: [{ type: "text", text }],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createUserMessage(id: string, text: string): SessionMessage {
|
|
||||||
return {
|
|
||||||
info: { id, role: "user" },
|
|
||||||
parts: [{ type: "text", text }],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function expectContinuationPrompts(promptCalls: string[], count: number): void {
|
|
||||||
expect(promptCalls).toHaveLength(count)
|
|
||||||
for (const call of promptCalls) {
|
|
||||||
expect(call.startsWith("continue")).toBe(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("gpt-permission-continuation", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
_resetForTesting()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("injects continue when the last GPT assistant reply asks for permission", async () => {
|
|
||||||
// given
|
|
||||||
const { ctx, promptCalls } = createMockPluginInput([
|
|
||||||
{
|
|
||||||
info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" },
|
|
||||||
parts: [{ type: "text", text: "I finished the analysis. If you want, I can apply the changes next." }],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
const hook = createGptPermissionContinuationHook(ctx)
|
|
||||||
|
|
||||||
// when
|
|
||||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
|
||||||
|
|
||||||
// then
|
|
||||||
expectContinuationPrompts(promptCalls, 1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does not inject when the last assistant model is not GPT", async () => {
|
|
||||||
// given
|
|
||||||
const { ctx, promptCalls } = createMockPluginInput([
|
|
||||||
{
|
|
||||||
info: {
|
|
||||||
id: "msg-1",
|
|
||||||
role: "assistant",
|
|
||||||
model: { providerID: "anthropic", modelID: "claude-sonnet-4" },
|
|
||||||
},
|
|
||||||
parts: [{ type: "text", text: "If you want, I can keep going." }],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
const hook = createGptPermissionContinuationHook(ctx)
|
|
||||||
|
|
||||||
// when
|
|
||||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
|
||||||
|
|
||||||
// then
|
|
||||||
expect(promptCalls).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does not inject when the last assistant reply is not a stall pattern", async () => {
|
|
||||||
// given
|
|
||||||
const { ctx, promptCalls } = createMockPluginInput([
|
|
||||||
{
|
|
||||||
info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" },
|
|
||||||
parts: [{ type: "text", text: "I completed the refactor and all tests pass." }],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
const hook = createGptPermissionContinuationHook(ctx)
|
|
||||||
|
|
||||||
// when
|
|
||||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
|
||||||
|
|
||||||
// then
|
|
||||||
expect(promptCalls).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does not inject when a permission phrase appears before the final sentence", async () => {
|
|
||||||
// given
|
|
||||||
const { ctx, promptCalls } = createMockPluginInput([
|
|
||||||
{
|
|
||||||
info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" },
|
|
||||||
parts: [{ type: "text", text: "If you want, I can keep going. The current work is complete." }],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
const hook = createGptPermissionContinuationHook(ctx)
|
|
||||||
|
|
||||||
// when
|
|
||||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
|
||||||
|
|
||||||
// then
|
|
||||||
expect(promptCalls).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does not inject when continuation is stopped for the session", async () => {
|
|
||||||
// given
|
|
||||||
const { ctx, promptCalls } = createMockPluginInput([
|
|
||||||
{
|
|
||||||
info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" },
|
|
||||||
parts: [{ type: "text", text: "If you want, I can continue with the fix." }],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
const hook = createGptPermissionContinuationHook(ctx, {
|
|
||||||
isContinuationStopped: (sessionID) => sessionID === "ses-1",
|
|
||||||
})
|
|
||||||
|
|
||||||
// when
|
|
||||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
|
||||||
|
|
||||||
// then
|
|
||||||
expect(promptCalls).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does not inject twice for the same assistant message", async () => {
|
|
||||||
// given
|
|
||||||
const { ctx, promptCalls } = createMockPluginInput([
|
|
||||||
{
|
|
||||||
info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" },
|
|
||||||
parts: [{ type: "text", text: "Would you like me to continue with the fix?" }],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
const hook = createGptPermissionContinuationHook(ctx)
|
|
||||||
|
|
||||||
// when
|
|
||||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
|
||||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
|
||||||
|
|
||||||
// then
|
|
||||||
expectContinuationPrompts(promptCalls, 1)
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("#given repeated GPT permission tails in the same session", () => {
|
|
||||||
describe("#when the permission phrases keep changing", () => {
|
|
||||||
test("stops injecting after three consecutive auto-continues", async () => {
|
|
||||||
// given
|
|
||||||
const messages: SessionMessage[] = [
|
|
||||||
createUserMessage("msg-0", "Please continue the fix."),
|
|
||||||
createAssistantMessage("msg-1", "If you want, I can apply the patch next."),
|
|
||||||
]
|
|
||||||
const { ctx, promptCalls } = createMockPluginInput(messages)
|
|
||||||
const hook = createGptPermissionContinuationHook(ctx)
|
|
||||||
|
|
||||||
// when
|
|
||||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
|
||||||
messages.push(createUserMessage("msg-2", "continue"))
|
|
||||||
messages.push(createAssistantMessage("msg-3", "Would you like me to continue with the tests?"))
|
|
||||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
|
||||||
messages.push(createUserMessage("msg-4", "continue"))
|
|
||||||
messages.push(createAssistantMessage("msg-5", "Do you want me to wire the remaining cleanup?"))
|
|
||||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
|
||||||
messages.push(createUserMessage("msg-6", "continue"))
|
|
||||||
messages.push(createAssistantMessage("msg-7", "Shall I finish the remaining updates?"))
|
|
||||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
|
||||||
|
|
||||||
// then
|
|
||||||
expectContinuationPrompts(promptCalls, 3)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("#when a real user message arrives between auto-continues", () => {
|
|
||||||
test("resets the consecutive auto-continue counter", async () => {
|
|
||||||
// given
|
|
||||||
const messages: SessionMessage[] = [
|
|
||||||
createUserMessage("msg-0", "Please continue the fix."),
|
|
||||||
createAssistantMessage("msg-1", "If you want, I can apply the patch next."),
|
|
||||||
]
|
|
||||||
const { ctx, promptCalls } = createMockPluginInput(messages)
|
|
||||||
const hook = createGptPermissionContinuationHook(ctx)
|
|
||||||
|
|
||||||
// when
|
|
||||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
|
||||||
messages.push(createUserMessage("msg-2", "continue"))
|
|
||||||
messages.push(createAssistantMessage("msg-3", "Would you like me to continue with the tests?"))
|
|
||||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
|
||||||
messages.push(createUserMessage("msg-4", "Please keep going and finish the cleanup."))
|
|
||||||
messages.push(createAssistantMessage("msg-5", "Do you want me to wire the remaining cleanup?"))
|
|
||||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
|
||||||
messages.push(createUserMessage("msg-6", "continue"))
|
|
||||||
messages.push(createAssistantMessage("msg-7", "Shall I finish the remaining updates?"))
|
|
||||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
|
||||||
messages.push(createUserMessage("msg-8", "continue"))
|
|
||||||
messages.push(createAssistantMessage("msg-9", "If you want, I can apply the final polish."))
|
|
||||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
|
||||||
messages.push(createUserMessage("msg-10", "continue"))
|
|
||||||
messages.push(createAssistantMessage("msg-11", "Would you like me to ship the final verification?"))
|
|
||||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
|
||||||
|
|
||||||
// then
|
|
||||||
expectContinuationPrompts(promptCalls, 5)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("#when the same permission phrase repeats after an auto-continue", () => {
|
|
||||||
test("stops immediately on stagnation", async () => {
|
|
||||||
// given
|
|
||||||
const messages: SessionMessage[] = [
|
|
||||||
createUserMessage("msg-0", "Please continue the fix."),
|
|
||||||
createAssistantMessage("msg-1", "If you want, I can apply the patch next."),
|
|
||||||
]
|
|
||||||
const { ctx, promptCalls } = createMockPluginInput(messages)
|
|
||||||
const hook = createGptPermissionContinuationHook(ctx)
|
|
||||||
|
|
||||||
// when
|
|
||||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
|
||||||
messages.push(createUserMessage("msg-2", "continue"))
|
|
||||||
messages.push(createAssistantMessage("msg-3", "If you want, I can apply the patch next."))
|
|
||||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
|
||||||
|
|
||||||
// then
|
|
||||||
expectContinuationPrompts(promptCalls, 1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("#when a user manually types continue after the cap is reached", () => {
|
|
||||||
test("resets the cap and allows another auto-continue", async () => {
|
|
||||||
// given
|
|
||||||
const messages: SessionMessage[] = [
|
|
||||||
createUserMessage("msg-0", "Please continue the fix."),
|
|
||||||
createAssistantMessage("msg-1", "If you want, I can apply the patch next."),
|
|
||||||
]
|
|
||||||
const { ctx, promptCalls } = createMockPluginInput(messages)
|
|
||||||
const hook = createGptPermissionContinuationHook(ctx)
|
|
||||||
|
|
||||||
// when
|
|
||||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
|
||||||
messages.push(createUserMessage("msg-2", "continue"))
|
|
||||||
messages.push(createAssistantMessage("msg-3", "Would you like me to continue with the tests?"))
|
|
||||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
|
||||||
messages.push(createUserMessage("msg-4", "continue"))
|
|
||||||
messages.push(createAssistantMessage("msg-5", "Do you want me to wire the remaining cleanup?"))
|
|
||||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
|
||||||
messages.push(createUserMessage("msg-6", "continue"))
|
|
||||||
messages.push(createAssistantMessage("msg-7", "Shall I finish the remaining updates?"))
|
|
||||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
|
||||||
messages.push(createUserMessage("msg-8", "continue"))
|
|
||||||
messages.push(createAssistantMessage("msg-9", "If you want, I can apply the final polish."))
|
|
||||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
|
||||||
|
|
||||||
// then
|
|
||||||
expectContinuationPrompts(promptCalls, 4)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does not inject when the session is a subagent session", async () => {
|
|
||||||
// given
|
|
||||||
const { ctx, promptCalls } = createMockPluginInput([
|
|
||||||
{
|
|
||||||
info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" },
|
|
||||||
parts: [{ type: "text", text: "If you want, I can continue with the fix." }],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
subagentSessions.add("ses-subagent")
|
|
||||||
const hook = createGptPermissionContinuationHook(ctx)
|
|
||||||
|
|
||||||
// when
|
|
||||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-subagent" } } })
|
|
||||||
|
|
||||||
// then
|
|
||||||
expect(promptCalls).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("includes assistant text context in the continuation prompt", async () => {
|
|
||||||
// given
|
|
||||||
const assistantText = "I finished the analysis. If you want, I can apply the changes next."
|
|
||||||
const { ctx, promptCalls } = createMockPluginInput([
|
|
||||||
{
|
|
||||||
info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" },
|
|
||||||
parts: [{ type: "text", text: assistantText }],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
const hook = createGptPermissionContinuationHook(ctx)
|
|
||||||
|
|
||||||
// when
|
|
||||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
|
||||||
|
|
||||||
// then
|
|
||||||
expect(promptCalls).toHaveLength(1)
|
|
||||||
expect(promptCalls[0].startsWith("continue")).toBe(true)
|
|
||||||
expect(promptCalls[0]).toContain("If you want, I can apply the changes next.")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
|
||||||
|
|
||||||
import { subagentSessions } from "../../features/claude-code-session-state"
|
|
||||||
import { normalizeSDKResponse } from "../../shared"
|
|
||||||
import { log } from "../../shared/logger"
|
|
||||||
|
|
||||||
import {
|
|
||||||
extractAssistantText,
|
|
||||||
getLastAssistantMessage,
|
|
||||||
isGptAssistantMessage,
|
|
||||||
type SessionMessage,
|
|
||||||
} from "./assistant-message"
|
|
||||||
import {
|
|
||||||
CONTINUATION_PROMPT,
|
|
||||||
HOOK_NAME,
|
|
||||||
MAX_CONSECUTIVE_AUTO_CONTINUES,
|
|
||||||
} from "./constants"
|
|
||||||
import { detectStallPattern, extractPermissionPhrase } from "./detector"
|
|
||||||
import { buildContextualContinuationPrompt } from "./prompt-builder"
|
|
||||||
import type { SessionStateStore } from "./session-state"
|
|
||||||
|
|
||||||
type SessionState = ReturnType<SessionStateStore["getState"]>
|
|
||||||
|
|
||||||
async function promptContinuation(
|
|
||||||
ctx: PluginInput,
|
|
||||||
sessionID: string,
|
|
||||||
assistantText: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const prompt = buildContextualContinuationPrompt(assistantText)
|
|
||||||
const payload = {
|
|
||||||
path: { id: sessionID },
|
|
||||||
body: {
|
|
||||||
parts: [{ type: "text" as const, text: prompt }],
|
|
||||||
},
|
|
||||||
query: { directory: ctx.directory },
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof ctx.client.session.promptAsync === "function") {
|
|
||||||
await ctx.client.session.promptAsync(payload)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await ctx.client.session.prompt(payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLastUserMessageBefore(
|
|
||||||
messages: SessionMessage[],
|
|
||||||
lastAssistantIndex: number,
|
|
||||||
): SessionMessage | null {
|
|
||||||
for (let index = lastAssistantIndex - 1; index >= 0; index--) {
|
|
||||||
if (messages[index].info?.role === "user") {
|
|
||||||
return messages[index]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAutoContinuationUserMessage(message: SessionMessage): boolean {
|
|
||||||
const text = extractAssistantText(message).trim().toLowerCase()
|
|
||||||
return text === CONTINUATION_PROMPT || text.startsWith(`${CONTINUATION_PROMPT}\n`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetAutoContinuationState(state: SessionState): void {
|
|
||||||
state.consecutiveAutoContinueCount = 0
|
|
||||||
state.awaitingAutoContinuationResponse = false
|
|
||||||
state.lastAutoContinuePermissionPhrase = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createGptPermissionContinuationHandler(args: {
|
|
||||||
ctx: PluginInput
|
|
||||||
sessionStateStore: SessionStateStore
|
|
||||||
isContinuationStopped?: (sessionID: string) => boolean
|
|
||||||
}): (input: { event: { type: string; properties?: unknown } }) => Promise<void> {
|
|
||||||
const { ctx, sessionStateStore, isContinuationStopped } = args
|
|
||||||
|
|
||||||
return async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
|
|
||||||
const properties = event.properties as Record<string, unknown> | undefined
|
|
||||||
|
|
||||||
if (event.type === "session.deleted") {
|
|
||||||
const sessionID = (properties?.info as { id?: string } | undefined)?.id
|
|
||||||
if (sessionID) {
|
|
||||||
sessionStateStore.cleanup(sessionID)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type !== "session.idle") return
|
|
||||||
|
|
||||||
const sessionID = properties?.sessionID as string | undefined
|
|
||||||
if (!sessionID) return
|
|
||||||
|
|
||||||
if (subagentSessions.has(sessionID)) {
|
|
||||||
log(`[${HOOK_NAME}] Skipped: session is a subagent`, { sessionID })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (isContinuationStopped?.(sessionID)) {
|
|
||||||
log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = sessionStateStore.getState(sessionID)
|
|
||||||
if (state.inFlight) {
|
|
||||||
log(`[${HOOK_NAME}] Skipped: prompt already in flight`, { sessionID })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const messagesResponse = await ctx.client.session.messages({
|
|
||||||
path: { id: sessionID },
|
|
||||||
query: { directory: ctx.directory },
|
|
||||||
})
|
|
||||||
const messages = normalizeSDKResponse(messagesResponse, [] as SessionMessage[], {
|
|
||||||
preferResponseOnMissingData: true,
|
|
||||||
})
|
|
||||||
const lastAssistantMessage = getLastAssistantMessage(messages)
|
|
||||||
if (!lastAssistantMessage) return
|
|
||||||
|
|
||||||
const lastAssistantIndex = messages.lastIndexOf(lastAssistantMessage)
|
|
||||||
const previousUserMessage = getLastUserMessageBefore(messages, lastAssistantIndex)
|
|
||||||
const previousUserMessageWasAutoContinuation =
|
|
||||||
previousUserMessage !== null
|
|
||||||
&& state.awaitingAutoContinuationResponse
|
|
||||||
&& isAutoContinuationUserMessage(previousUserMessage)
|
|
||||||
|
|
||||||
if (previousUserMessageWasAutoContinuation) {
|
|
||||||
state.awaitingAutoContinuationResponse = false
|
|
||||||
} else if (previousUserMessage) {
|
|
||||||
resetAutoContinuationState(state)
|
|
||||||
} else {
|
|
||||||
state.awaitingAutoContinuationResponse = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const messageID = lastAssistantMessage.info?.id
|
|
||||||
if (messageID && state.lastHandledMessageID === messageID) {
|
|
||||||
log(`[${HOOK_NAME}] Skipped: already handled assistant message`, { sessionID, messageID })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastAssistantMessage.info?.error) {
|
|
||||||
log(`[${HOOK_NAME}] Skipped: last assistant message has error`, { sessionID, messageID })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isGptAssistantMessage(lastAssistantMessage)) {
|
|
||||||
log(`[${HOOK_NAME}] Skipped: last assistant model is not GPT`, { sessionID, messageID })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const assistantText = extractAssistantText(lastAssistantMessage)
|
|
||||||
if (!detectStallPattern(assistantText)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const permissionPhrase = extractPermissionPhrase(assistantText)
|
|
||||||
if (!permissionPhrase) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.consecutiveAutoContinueCount >= MAX_CONSECUTIVE_AUTO_CONTINUES) {
|
|
||||||
state.lastHandledMessageID = messageID
|
|
||||||
log(`[${HOOK_NAME}] Skipped: reached max consecutive auto-continues`, {
|
|
||||||
sessionID,
|
|
||||||
messageID,
|
|
||||||
consecutiveAutoContinueCount: state.consecutiveAutoContinueCount,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
state.consecutiveAutoContinueCount >= 1
|
|
||||||
&& state.lastAutoContinuePermissionPhrase === permissionPhrase
|
|
||||||
) {
|
|
||||||
state.lastHandledMessageID = messageID
|
|
||||||
log(`[${HOOK_NAME}] Skipped: repeated permission phrase after auto-continue`, {
|
|
||||||
sessionID,
|
|
||||||
messageID,
|
|
||||||
permissionPhrase,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
state.inFlight = true
|
|
||||||
await promptContinuation(ctx, sessionID, assistantText)
|
|
||||||
state.lastHandledMessageID = messageID
|
|
||||||
state.consecutiveAutoContinueCount += 1
|
|
||||||
state.awaitingAutoContinuationResponse = true
|
|
||||||
state.lastAutoContinuePermissionPhrase = permissionPhrase
|
|
||||||
state.lastInjectedAt = Date.now()
|
|
||||||
log(`[${HOOK_NAME}] Injected continuation prompt`, { sessionID, messageID })
|
|
||||||
} catch (error) {
|
|
||||||
log(`[${HOOK_NAME}] Failed to inject continuation prompt`, {
|
|
||||||
sessionID,
|
|
||||||
error: String(error),
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
state.inFlight = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
|
||||||
|
|
||||||
import { createGptPermissionContinuationHandler } from "./handler"
|
|
||||||
import { createSessionStateStore } from "./session-state"
|
|
||||||
|
|
||||||
export type GptPermissionContinuationHook = {
|
|
||||||
handler: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
|
|
||||||
wasRecentlyInjected: (sessionID: string) => boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createGptPermissionContinuationHook(
|
|
||||||
ctx: PluginInput,
|
|
||||||
options?: {
|
|
||||||
isContinuationStopped?: (sessionID: string) => boolean
|
|
||||||
},
|
|
||||||
): GptPermissionContinuationHook {
|
|
||||||
const sessionStateStore = createSessionStateStore()
|
|
||||||
|
|
||||||
return {
|
|
||||||
handler: createGptPermissionContinuationHandler({
|
|
||||||
ctx,
|
|
||||||
sessionStateStore,
|
|
||||||
isContinuationStopped: options?.isContinuationStopped,
|
|
||||||
}),
|
|
||||||
wasRecentlyInjected(sessionID: string): boolean {
|
|
||||||
return sessionStateStore.wasRecentlyInjected(sessionID, 5_000)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { CONTINUATION_PROMPT } from "./constants"
|
|
||||||
|
|
||||||
const CONTEXT_LINE_COUNT = 5
|
|
||||||
|
|
||||||
export function buildContextualContinuationPrompt(assistantText: string): string {
|
|
||||||
const lines = assistantText.split("\n").map((line) => line.trim()).filter(Boolean)
|
|
||||||
const contextLines = lines.slice(-CONTEXT_LINE_COUNT)
|
|
||||||
|
|
||||||
if (contextLines.length === 0) {
|
|
||||||
return CONTINUATION_PROMPT
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${CONTINUATION_PROMPT}\n\n[Your last response ended with:]\n${contextLines.join("\n")}`
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
type SessionState = {
|
|
||||||
inFlight: boolean
|
|
||||||
consecutiveAutoContinueCount: number
|
|
||||||
awaitingAutoContinuationResponse: boolean
|
|
||||||
lastHandledMessageID?: string
|
|
||||||
lastAutoContinuePermissionPhrase?: string
|
|
||||||
lastInjectedAt?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SessionStateStore = ReturnType<typeof createSessionStateStore>
|
|
||||||
|
|
||||||
export function createSessionStateStore() {
|
|
||||||
const states = new Map<string, SessionState>()
|
|
||||||
|
|
||||||
const getState = (sessionID: string): SessionState => {
|
|
||||||
const existing = states.get(sessionID)
|
|
||||||
if (existing) return existing
|
|
||||||
|
|
||||||
const created: SessionState = {
|
|
||||||
inFlight: false,
|
|
||||||
consecutiveAutoContinueCount: 0,
|
|
||||||
awaitingAutoContinuationResponse: false,
|
|
||||||
}
|
|
||||||
states.set(sessionID, created)
|
|
||||||
return created
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
getState,
|
|
||||||
wasRecentlyInjected(sessionID: string, windowMs: number): boolean {
|
|
||||||
const state = states.get(sessionID)
|
|
||||||
if (!state?.lastInjectedAt) return false
|
|
||||||
return Date.now() - state.lastInjectedAt <= windowMs
|
|
||||||
},
|
|
||||||
cleanup(sessionID: string): void {
|
|
||||||
states.delete(sessionID)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
|
|
||||||
import { createTodoContinuationEnforcer } from "../todo-continuation-enforcer"
|
|
||||||
import { createGptPermissionContinuationHook } from "."
|
|
||||||
|
|
||||||
describe("gpt-permission-continuation coordination", () => {
|
|
||||||
test("injects only once when GPT permission continuation and todo continuation are both eligible", async () => {
|
|
||||||
// given
|
|
||||||
const promptCalls: string[] = []
|
|
||||||
const toastCalls: string[] = []
|
|
||||||
const sessionID = "ses-dual-continuation"
|
|
||||||
const ctx = {
|
|
||||||
directory: "/tmp/test",
|
|
||||||
client: {
|
|
||||||
session: {
|
|
||||||
messages: async () => ({
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" },
|
|
||||||
parts: [{ type: "text", text: "If you want, I can implement the fix next." }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
todo: async () => ({
|
|
||||||
data: [{ id: "1", content: "Task 1", status: "pending", priority: "high" }],
|
|
||||||
}),
|
|
||||||
prompt: async (input: { body: { parts: Array<{ text: string }> } }) => {
|
|
||||||
promptCalls.push(input.body.parts[0]?.text ?? "")
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
promptAsync: async (input: { body: { parts: Array<{ text: string }> } }) => {
|
|
||||||
promptCalls.push(input.body.parts[0]?.text ?? "")
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
tui: {
|
|
||||||
showToast: async (input: { body: { title: string } }) => {
|
|
||||||
toastCalls.push(input.body.title)
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as any
|
|
||||||
|
|
||||||
const gptPermissionContinuation = createGptPermissionContinuationHook(ctx)
|
|
||||||
const todoContinuationEnforcer = createTodoContinuationEnforcer(ctx, {
|
|
||||||
shouldSkipContinuation: (id) => gptPermissionContinuation.wasRecentlyInjected(id),
|
|
||||||
})
|
|
||||||
|
|
||||||
// when
|
|
||||||
await gptPermissionContinuation.handler({
|
|
||||||
event: { type: "session.idle", properties: { sessionID } },
|
|
||||||
})
|
|
||||||
await todoContinuationEnforcer.handler({
|
|
||||||
event: { type: "session.idle", properties: { sessionID } },
|
|
||||||
})
|
|
||||||
|
|
||||||
// then
|
|
||||||
expect(promptCalls).toHaveLength(1)
|
|
||||||
expect(promptCalls[0].startsWith("continue")).toBe(true)
|
|
||||||
expect(promptCalls[0]).toContain("If you want, I can implement the fix next.")
|
|
||||||
expect(toastCalls).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -30,7 +30,6 @@ export { createCategorySkillReminderHook } from "./category-skill-reminder";
|
|||||||
export { createRalphLoopHook, type RalphLoopHook } from "./ralph-loop";
|
export { createRalphLoopHook, type RalphLoopHook } from "./ralph-loop";
|
||||||
export { createNoSisyphusGptHook } from "./no-sisyphus-gpt";
|
export { createNoSisyphusGptHook } from "./no-sisyphus-gpt";
|
||||||
export { createNoHephaestusNonGptHook } from "./no-hephaestus-non-gpt";
|
export { createNoHephaestusNonGptHook } from "./no-hephaestus-non-gpt";
|
||||||
export { createGptPermissionContinuationHook, type GptPermissionContinuationHook } from "./gpt-permission-continuation"
|
|
||||||
export { createAutoSlashCommandHook } from "./auto-slash-command";
|
export { createAutoSlashCommandHook } from "./auto-slash-command";
|
||||||
export { createEditErrorRecoveryHook } from "./edit-error-recovery";
|
export { createEditErrorRecoveryHook } from "./edit-error-recovery";
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ export function createTodoContinuationHandler(args: {
|
|||||||
backgroundManager?: BackgroundManager
|
backgroundManager?: BackgroundManager
|
||||||
skipAgents?: string[]
|
skipAgents?: string[]
|
||||||
isContinuationStopped?: (sessionID: string) => boolean
|
isContinuationStopped?: (sessionID: string) => boolean
|
||||||
shouldSkipContinuation?: (sessionID: string) => boolean
|
|
||||||
}): (input: { event: { type: string; properties?: unknown } }) => Promise<void> {
|
}): (input: { event: { type: string; properties?: unknown } }) => Promise<void> {
|
||||||
const {
|
const {
|
||||||
ctx,
|
ctx,
|
||||||
@@ -25,7 +24,6 @@ export function createTodoContinuationHandler(args: {
|
|||||||
backgroundManager,
|
backgroundManager,
|
||||||
skipAgents = DEFAULT_SKIP_AGENTS,
|
skipAgents = DEFAULT_SKIP_AGENTS,
|
||||||
isContinuationStopped,
|
isContinuationStopped,
|
||||||
shouldSkipContinuation,
|
|
||||||
} = args
|
} = args
|
||||||
|
|
||||||
return async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
|
return async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
|
||||||
@@ -58,7 +56,6 @@ export function createTodoContinuationHandler(args: {
|
|||||||
backgroundManager,
|
backgroundManager,
|
||||||
skipAgents,
|
skipAgents,
|
||||||
isContinuationStopped,
|
isContinuationStopped,
|
||||||
shouldSkipContinuation,
|
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ export async function handleSessionIdle(args: {
|
|||||||
backgroundManager?: BackgroundManager
|
backgroundManager?: BackgroundManager
|
||||||
skipAgents?: string[]
|
skipAgents?: string[]
|
||||||
isContinuationStopped?: (sessionID: string) => boolean
|
isContinuationStopped?: (sessionID: string) => boolean
|
||||||
shouldSkipContinuation?: (sessionID: string) => boolean
|
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const {
|
const {
|
||||||
ctx,
|
ctx,
|
||||||
@@ -39,7 +38,6 @@ export async function handleSessionIdle(args: {
|
|||||||
backgroundManager,
|
backgroundManager,
|
||||||
skipAgents = DEFAULT_SKIP_AGENTS,
|
skipAgents = DEFAULT_SKIP_AGENTS,
|
||||||
isContinuationStopped,
|
isContinuationStopped,
|
||||||
shouldSkipContinuation,
|
|
||||||
} = args
|
} = args
|
||||||
|
|
||||||
log(`[${HOOK_NAME}] session.idle`, { sessionID })
|
log(`[${HOOK_NAME}] session.idle`, { sessionID })
|
||||||
@@ -174,11 +172,6 @@ export async function handleSessionIdle(args: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldSkipContinuation?.(sessionID)) {
|
|
||||||
log(`[${HOOK_NAME}] Skipped: another continuation hook already injected`, { sessionID })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, incompleteCount, todos)
|
const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, incompleteCount, todos)
|
||||||
if (shouldStopForStagnation({ sessionID, incompleteCount, progressUpdate })) {
|
if (shouldStopForStagnation({ sessionID, incompleteCount, progressUpdate })) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ export function createTodoContinuationEnforcer(
|
|||||||
backgroundManager,
|
backgroundManager,
|
||||||
skipAgents = DEFAULT_SKIP_AGENTS,
|
skipAgents = DEFAULT_SKIP_AGENTS,
|
||||||
isContinuationStopped,
|
isContinuationStopped,
|
||||||
shouldSkipContinuation,
|
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
const sessionStateStore = createSessionStateStore()
|
const sessionStateStore = createSessionStateStore()
|
||||||
@@ -43,7 +42,6 @@ export function createTodoContinuationEnforcer(
|
|||||||
backgroundManager,
|
backgroundManager,
|
||||||
skipAgents,
|
skipAgents,
|
||||||
isContinuationStopped,
|
isContinuationStopped,
|
||||||
shouldSkipContinuation,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const cancelAllCountdowns = (): void => {
|
const cancelAllCountdowns = (): void => {
|
||||||
|
|||||||
@@ -1706,27 +1706,6 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
expect(promptCalls).toHaveLength(0)
|
expect(promptCalls).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should not inject when shouldSkipContinuation returns true", async () => {
|
|
||||||
// given - session already handled by another continuation hook
|
|
||||||
const sessionID = "main-skip-other-continuation"
|
|
||||||
setMainSession(sessionID)
|
|
||||||
|
|
||||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
|
|
||||||
shouldSkipContinuation: (id) => id === sessionID,
|
|
||||||
})
|
|
||||||
|
|
||||||
// when - session goes idle
|
|
||||||
await hook.handler({
|
|
||||||
event: { type: "session.idle", properties: { sessionID } },
|
|
||||||
})
|
|
||||||
|
|
||||||
await fakeTimers.advanceBy(3000)
|
|
||||||
|
|
||||||
// then - no countdown toast or continuation injection
|
|
||||||
expect(toastCalls).toHaveLength(0)
|
|
||||||
expect(promptCalls).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("should not inject when isContinuationStopped becomes true during countdown", async () => {
|
test("should not inject when isContinuationStopped becomes true during countdown", async () => {
|
||||||
// given - session where continuation is not stopped at idle time but stops during countdown
|
// given - session where continuation is not stopped at idle time but stops during countdown
|
||||||
const sessionID = "main-race-condition"
|
const sessionID = "main-race-condition"
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ export interface TodoContinuationEnforcerOptions {
|
|||||||
backgroundManager?: BackgroundManager
|
backgroundManager?: BackgroundManager
|
||||||
skipAgents?: string[]
|
skipAgents?: string[]
|
||||||
isContinuationStopped?: (sessionID: string) => boolean
|
isContinuationStopped?: (sessionID: string) => boolean
|
||||||
shouldSkipContinuation?: (sessionID: string) => boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TodoContinuationEnforcer {
|
export interface TodoContinuationEnforcer {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
|
|
||||||
Core glue layer. 20 source files assembling the 8 OpenCode hook handlers and composing 46 hooks into the PluginInterface. Every handler file corresponds to one OpenCode hook type.
|
Core glue layer. 20 source files assembling the 8 OpenCode hook handlers and composing 45 hooks into the PluginInterface. Every handler file corresponds to one OpenCode hook type.
|
||||||
|
|
||||||
## HANDLER FILES
|
## HANDLER FILES
|
||||||
|
|
||||||
|
|||||||
@@ -195,7 +195,6 @@ export function createEventHandler(args: {
|
|||||||
await Promise.resolve(hooks.claudeCodeHooks?.event?.(input));
|
await Promise.resolve(hooks.claudeCodeHooks?.event?.(input));
|
||||||
await Promise.resolve(hooks.backgroundNotificationHook?.event?.(input));
|
await Promise.resolve(hooks.backgroundNotificationHook?.event?.(input));
|
||||||
await Promise.resolve(hooks.sessionNotification?.(input));
|
await Promise.resolve(hooks.sessionNotification?.(input));
|
||||||
await Promise.resolve(hooks.gptPermissionContinuation?.handler?.(input));
|
|
||||||
await Promise.resolve(hooks.todoContinuationEnforcer?.handler?.(input));
|
await Promise.resolve(hooks.todoContinuationEnforcer?.handler?.(input));
|
||||||
await Promise.resolve(hooks.unstableAgentBabysitter?.event?.(input));
|
await Promise.resolve(hooks.unstableAgentBabysitter?.event?.(input));
|
||||||
await Promise.resolve(hooks.contextWindowMonitor?.event?.(input));
|
await Promise.resolve(hooks.contextWindowMonitor?.event?.(input));
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type { BackgroundManager } from "../../features/background-agent"
|
|||||||
import type { PluginContext } from "../types"
|
import type { PluginContext } from "../types"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createGptPermissionContinuationHook,
|
|
||||||
createTodoContinuationEnforcer,
|
createTodoContinuationEnforcer,
|
||||||
createBackgroundNotificationHook,
|
createBackgroundNotificationHook,
|
||||||
createStopContinuationGuardHook,
|
createStopContinuationGuardHook,
|
||||||
@@ -15,7 +14,6 @@ import { safeCreateHook } from "../../shared/safe-create-hook"
|
|||||||
import { createUnstableAgentBabysitter } from "../unstable-agent-babysitter"
|
import { createUnstableAgentBabysitter } from "../unstable-agent-babysitter"
|
||||||
|
|
||||||
export type ContinuationHooks = {
|
export type ContinuationHooks = {
|
||||||
gptPermissionContinuation: ReturnType<typeof createGptPermissionContinuationHook> | null
|
|
||||||
stopContinuationGuard: ReturnType<typeof createStopContinuationGuardHook> | null
|
stopContinuationGuard: ReturnType<typeof createStopContinuationGuardHook> | null
|
||||||
compactionContextInjector: ReturnType<typeof createCompactionContextInjector> | null
|
compactionContextInjector: ReturnType<typeof createCompactionContextInjector> | null
|
||||||
compactionTodoPreserver: ReturnType<typeof createCompactionTodoPreserverHook> | null
|
compactionTodoPreserver: ReturnType<typeof createCompactionTodoPreserverHook> | null
|
||||||
@@ -57,13 +55,6 @@ export function createContinuationHooks(args: {
|
|||||||
}))
|
}))
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const gptPermissionContinuation = isHookEnabled("gpt-permission-continuation")
|
|
||||||
? safeHook("gpt-permission-continuation", () =>
|
|
||||||
createGptPermissionContinuationHook(ctx, {
|
|
||||||
isContinuationStopped: stopContinuationGuard?.isStopped,
|
|
||||||
}))
|
|
||||||
: null
|
|
||||||
|
|
||||||
const compactionContextInjector = isHookEnabled("compaction-context-injector")
|
const compactionContextInjector = isHookEnabled("compaction-context-injector")
|
||||||
? safeHook("compaction-context-injector", () =>
|
? safeHook("compaction-context-injector", () =>
|
||||||
createCompactionContextInjector({ ctx, backgroundManager }))
|
createCompactionContextInjector({ ctx, backgroundManager }))
|
||||||
@@ -78,8 +69,6 @@ export function createContinuationHooks(args: {
|
|||||||
createTodoContinuationEnforcer(ctx, {
|
createTodoContinuationEnforcer(ctx, {
|
||||||
backgroundManager,
|
backgroundManager,
|
||||||
isContinuationStopped: stopContinuationGuard?.isStopped,
|
isContinuationStopped: stopContinuationGuard?.isStopped,
|
||||||
shouldSkipContinuation: (sessionID: string) =>
|
|
||||||
gptPermissionContinuation?.wasRecentlyInjected(sessionID) ?? false,
|
|
||||||
}))
|
}))
|
||||||
: null
|
: null
|
||||||
|
|
||||||
@@ -122,15 +111,12 @@ export function createContinuationHooks(args: {
|
|||||||
backgroundManager,
|
backgroundManager,
|
||||||
isContinuationStopped: (sessionID: string) =>
|
isContinuationStopped: (sessionID: string) =>
|
||||||
stopContinuationGuard?.isStopped(sessionID) ?? false,
|
stopContinuationGuard?.isStopped(sessionID) ?? false,
|
||||||
shouldSkipContinuation: (sessionID: string) =>
|
|
||||||
gptPermissionContinuation?.wasRecentlyInjected(sessionID) ?? false,
|
|
||||||
agentOverrides: pluginConfig.agents,
|
agentOverrides: pluginConfig.agents,
|
||||||
autoCommit: pluginConfig.start_work?.auto_commit,
|
autoCommit: pluginConfig.start_work?.auto_commit,
|
||||||
}))
|
}))
|
||||||
: null
|
: null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
gptPermissionContinuation,
|
|
||||||
stopContinuationGuard,
|
stopContinuationGuard,
|
||||||
compactionContextInjector,
|
compactionContextInjector,
|
||||||
compactionTodoPreserver,
|
compactionTodoPreserver,
|
||||||
|
|||||||
@@ -289,6 +289,19 @@ describe("migrateHookNames", () => {
|
|||||||
expect(removed).toHaveLength(1)
|
expect(removed).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("removes gpt-permission-continuation from disabled hooks", () => {
|
||||||
|
// given: Config with removed GPT permission continuation hook
|
||||||
|
const hooks = ["gpt-permission-continuation", "comment-checker"]
|
||||||
|
|
||||||
|
// when: Migrate hook names
|
||||||
|
const { migrated, changed, removed } = migrateHookNames(hooks)
|
||||||
|
|
||||||
|
// then: Removed hook should be filtered out
|
||||||
|
expect(changed).toBe(true)
|
||||||
|
expect(migrated).toEqual(["comment-checker"])
|
||||||
|
expect(removed).toEqual(["gpt-permission-continuation"])
|
||||||
|
})
|
||||||
|
|
||||||
test("handles mixed migration and removal", () => {
|
test("handles mixed migration and removal", () => {
|
||||||
// given: Config with both legacy rename and removed hooks
|
// given: Config with both legacy rename and removed hooks
|
||||||
const hooks = ["anthropic-auto-compact", "preemptive-compaction", "sisyphus-orchestrator"]
|
const hooks = ["anthropic-auto-compact", "preemptive-compaction", "sisyphus-orchestrator"]
|
||||||
@@ -413,6 +426,20 @@ describe("migrateConfigFile", () => {
|
|||||||
expect(rawConfig.disabled_hooks).toEqual(["comment-checker"])
|
expect(rawConfig.disabled_hooks).toEqual(["comment-checker"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("removes gpt-permission-continuation from disabled_hooks", () => {
|
||||||
|
// given: Config with removed GPT permission continuation hook
|
||||||
|
const rawConfig: Record<string, unknown> = {
|
||||||
|
disabled_hooks: ["gpt-permission-continuation", "comment-checker"],
|
||||||
|
}
|
||||||
|
|
||||||
|
// when: Migrate config file
|
||||||
|
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||||
|
|
||||||
|
// then: Removed hook should be filtered out
|
||||||
|
expect(needsWrite).toBe(true)
|
||||||
|
expect(rawConfig.disabled_hooks).toEqual(["comment-checker"])
|
||||||
|
})
|
||||||
|
|
||||||
test("does not write if no migration needed", () => {
|
test("does not write if no migration needed", () => {
|
||||||
// given: Config with current names
|
// given: Config with current names
|
||||||
const rawConfig: Record<string, unknown> = {
|
const rawConfig: Record<string, unknown> = {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const HOOK_NAME_MAP: Record<string, string | null> = {
|
|||||||
// Removed hooks (v3.0.0) - will be filtered out and user warned
|
// Removed hooks (v3.0.0) - will be filtered out and user warned
|
||||||
"empty-message-sanitizer": null,
|
"empty-message-sanitizer": null,
|
||||||
"delegate-task-english-directive": null,
|
"delegate-task-english-directive": null,
|
||||||
|
"gpt-permission-continuation": null,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function migrateHookNames(
|
export function migrateHookNames(
|
||||||
|
|||||||
Reference in New Issue
Block a user