Merge branch 'dev' into fix/analyze-mode-load-skills-hint
This commit is contained in:
+138
-50
@@ -1,79 +1,167 @@
|
||||
# HOOKS KNOWLEDGE BASE
|
||||
# src/hooks/ — 48 Lifecycle Hooks
|
||||
|
||||
**Generated:** 2026-03-06
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
31 lifecycle hooks intercepting/modifying agent behavior. Events: PreToolUse, PostToolUse, UserPromptSubmit, Stop, onSummarize.
|
||||
48 hooks across dedicated modules and standalone files. Three-tier composition: Core(39) + Continuation(7) + Skill(2). All hooks follow `createXXXHook(deps) → HookFunction` factory pattern.
|
||||
|
||||
## HOOK TIERS
|
||||
|
||||
### Tier 1: Session Hooks (23) — `create-session-hooks.ts`
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
hooks/
|
||||
├── atlas/ # Main orchestration (773 lines)
|
||||
├── anthropic-context-window-limit-recovery/ # Auto-summarize
|
||||
├── todo-continuation-enforcer.ts # Force TODO completion (489 lines)
|
||||
├── ralph-loop/ # Self-referential dev loop
|
||||
├── claude-code-hooks/ # settings.json compat layer - see AGENTS.md
|
||||
├── comment-checker/ # Prevents AI slop
|
||||
├── atlas/ # Main orchestration (757 lines)
|
||||
├── anthropic-context-window-limit-recovery/ # Auto-summarize
|
||||
├── anthropic-effort/ # Reasoning effort level adjustment
|
||||
├── anthropic-image-context/ # Image context handling for Anthropic
|
||||
├── auto-slash-command/ # Detects /command patterns
|
||||
├── rules-injector/ # Conditional rules
|
||||
├── auto-update-checker/ # Plugin update check
|
||||
├── background-notification/ # OS notification
|
||||
├── beast-mode-system/ # Beast mode system prompt injection
|
||||
├── 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
|
||||
├── thinking-block-validator/ # Ensures valid <thinking>
|
||||
├── context-window-monitor.ts # Reminds of headroom
|
||||
├── session-recovery/ # Auto-recovers from crashes
|
||||
├── think-mode/ # Dynamic thinking budget
|
||||
├── keyword-detector/ # ultrawork/search/analyze modes
|
||||
├── background-notification/ # OS notification
|
||||
├── prometheus-md-only/ # Planner read-only mode
|
||||
├── agent-usage-reminder/ # Specialized agent hints
|
||||
├── auto-update-checker/ # Plugin update check
|
||||
├── tool-output-truncator.ts # Prevents context bloat
|
||||
├── compaction-context-injector/ # Injects context on compaction
|
||||
├── delegate-task-retry/ # Retries failed delegations
|
||||
├── 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
|
||||
├── 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
|
||||
├── question-label-truncator/ # Auto-truncates question labels >30 chars
|
||||
├── 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
|
||||
├── unstable-agent-babysitter/ # Monitor unstable agent behavior
|
||||
├── write-existing-file-guard/ # Require Read before Write
|
||||
└── index.ts # Hook aggregation + registration
|
||||
```
|
||||
|
||||
## HOOK EVENTS
|
||||
| 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 |
|
||||
|
||||
| Event | Timing | Can Block | Use Case |
|
||||
|-------|--------|-----------|----------|
|
||||
| PreToolUse | Before tool | Yes | Validate/modify inputs |
|
||||
| PostToolUse | After tool | No | Append warnings, truncate |
|
||||
| UserPromptSubmit | On prompt | Yes | Keyword detection |
|
||||
| Stop | Session idle | No | Auto-continue |
|
||||
| onSummarize | Compaction | No | Preserve state |
|
||||
### Tier 2: Tool Guard Hooks (12) — `create-tool-guard-hooks.ts`
|
||||
|
||||
## EXECUTION ORDER
|
||||
| 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 |
|
||||
| hashlineReadEnhancer | tool.execute.after | Enhance Read output with line hashes |
|
||||
| jsonErrorRecovery | tool.execute.after | Detect JSON parse errors, inject correction reminder |
|
||||
|
||||
**chat.message**: keywordDetector → claudeCodeHooks → autoSlashCommand → startWork → ralphLoop
|
||||
### Tier 3: Transform Hooks (4) — `create-transform-hooks.ts`
|
||||
|
||||
**tool.execute.before**: claudeCodeHooks → nonInteractiveEnv → commentChecker → directoryAgentsInjector → rulesInjector
|
||||
| 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 |
|
||||
|
||||
**tool.execute.after**: editErrorRecovery → delegateTaskRetry → commentChecker → toolOutputTruncator → claudeCodeHooks
|
||||
### Tier 4: Continuation Hooks (7) — `create-continuation-hooks.ts`
|
||||
|
||||
## HOW TO ADD
|
||||
| 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 |
|
||||
|
||||
1. Create `src/hooks/name/` with `index.ts` exporting `createMyHook(ctx)`
|
||||
2. Add hook name to `HookNameSchema` in `src/config/schema.ts`
|
||||
3. Register in `src/index.ts`:
|
||||
```typescript
|
||||
const myHook = isHookEnabled("my-hook") ? createMyHook(ctx) : null
|
||||
```
|
||||
### Tier 5: Skill Hooks (2) — `create-skill-hooks.ts`
|
||||
|
||||
## PATTERNS
|
||||
| Hook | Event | Purpose |
|
||||
|------|-------|---------|
|
||||
| categorySkillReminder | chat.message | Remind about category+skill delegation |
|
||||
| autoSlashCommand | chat.message | Auto-detect `/command` in user input |
|
||||
|
||||
- **Session-scoped state**: `Map<sessionID, Set<string>>`
|
||||
- **Conditional execution**: Check `input.tool` before processing
|
||||
- **Output modification**: `output.output += "\n${REMINDER}"`
|
||||
## KEY HOOKS (COMPLEX)
|
||||
|
||||
## ANTI-PATTERNS
|
||||
### anthropic-context-window-limit-recovery (31 files, ~2232 LOC)
|
||||
Multi-strategy recovery when hitting context limits. Strategies: truncation, compaction, summarization.
|
||||
|
||||
- **Blocking non-critical**: Use PostToolUse warnings instead
|
||||
- **Heavy computation**: Keep PreToolUse light
|
||||
- **Redundant injection**: Track injected files
|
||||
### 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
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { join } from "node:path";
|
||||
import { getOpenCodeStorageDir } from "../../shared/data-path";
|
||||
|
||||
export const OPENCODE_STORAGE = getOpenCodeStorageDir();
|
||||
import { OPENCODE_STORAGE } from "../../shared";
|
||||
export const AGENT_USAGE_REMINDER_STORAGE = join(
|
||||
OPENCODE_STORAGE,
|
||||
"agent-usage-reminder",
|
||||
@@ -24,7 +22,7 @@ export const TARGET_TOOLS = new Set([
|
||||
export const AGENT_TOOLS = new Set([
|
||||
"task",
|
||||
"call_omo_agent",
|
||||
"delegate_task",
|
||||
"task",
|
||||
]);
|
||||
|
||||
export const REMINDER_MESSAGE = `
|
||||
@@ -32,13 +30,13 @@ export const REMINDER_MESSAGE = `
|
||||
|
||||
You called a search/fetch tool directly without leveraging specialized agents.
|
||||
|
||||
RECOMMENDED: Use delegate_task with explore/librarian agents for better results:
|
||||
RECOMMENDED: Use task with explore/librarian agents for better results:
|
||||
|
||||
\`\`\`
|
||||
// Parallel exploration - fire multiple agents simultaneously
|
||||
delegate_task(agent="explore", prompt="Find all files matching pattern X")
|
||||
delegate_task(agent="explore", prompt="Search for implementation of Y")
|
||||
delegate_task(agent="librarian", prompt="Lookup documentation for Z")
|
||||
task(agent="explore", prompt="Find all files matching pattern X")
|
||||
task(agent="explore", prompt="Search for implementation of Y")
|
||||
task(agent="librarian", prompt="Lookup documentation for Z")
|
||||
|
||||
// Then continue your work while they run in background
|
||||
// System will notify you when each completes
|
||||
@@ -50,5 +48,5 @@ WHY:
|
||||
- Specialized agents have domain expertise
|
||||
- Reduces context window usage in main session
|
||||
|
||||
ALWAYS prefer: Multiple parallel delegate_task calls > Direct tool calls
|
||||
ALWAYS prefer: Multiple parallel task calls > Direct tool calls
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
import {
|
||||
loadAgentUsageState,
|
||||
saveAgentUsageState,
|
||||
clearAgentUsageState,
|
||||
} from "./storage";
|
||||
import { TARGET_TOOLS, AGENT_TOOLS, REMINDER_MESSAGE } from "./constants";
|
||||
import type { AgentUsageState } from "./types";
|
||||
import { getSessionAgent } from "../../features/claude-code-session-state";
|
||||
import { getAgentConfigKey } from "../../shared/agent-display-names";
|
||||
|
||||
interface ToolExecuteInput {
|
||||
tool: string;
|
||||
sessionID: string;
|
||||
callID: string;
|
||||
}
|
||||
|
||||
interface ToolExecuteOutput {
|
||||
title: string;
|
||||
output: string;
|
||||
metadata: unknown;
|
||||
}
|
||||
|
||||
interface EventInput {
|
||||
event: {
|
||||
type: string;
|
||||
properties?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Only orchestrator agents should receive usage reminders.
|
||||
* Subagents (explore, librarian, oracle, etc.) are the targets of delegation,
|
||||
* so reminding them to delegate to themselves is counterproductive.
|
||||
*/
|
||||
const ORCHESTRATOR_AGENTS = new Set([
|
||||
"sisyphus",
|
||||
"sisyphus-junior",
|
||||
"atlas",
|
||||
"hephaestus",
|
||||
"prometheus",
|
||||
]);
|
||||
|
||||
function isOrchestratorAgent(agentName: string): boolean {
|
||||
return ORCHESTRATOR_AGENTS.has(getAgentConfigKey(agentName));
|
||||
}
|
||||
|
||||
export function createAgentUsageReminderHook(_ctx: PluginInput) {
|
||||
const sessionStates = new Map<string, AgentUsageState>();
|
||||
|
||||
function getOrCreateState(sessionID: string): AgentUsageState {
|
||||
if (!sessionStates.has(sessionID)) {
|
||||
const persisted = loadAgentUsageState(sessionID);
|
||||
const state: AgentUsageState = persisted ?? {
|
||||
sessionID,
|
||||
agentUsed: false,
|
||||
reminderCount: 0,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
sessionStates.set(sessionID, state);
|
||||
}
|
||||
return sessionStates.get(sessionID)!;
|
||||
}
|
||||
|
||||
function markAgentUsed(sessionID: string): void {
|
||||
const state = getOrCreateState(sessionID);
|
||||
state.agentUsed = true;
|
||||
state.updatedAt = Date.now();
|
||||
saveAgentUsageState(state);
|
||||
}
|
||||
|
||||
function resetState(sessionID: string): void {
|
||||
sessionStates.delete(sessionID);
|
||||
clearAgentUsageState(sessionID);
|
||||
}
|
||||
|
||||
const toolExecuteAfter = async (
|
||||
input: ToolExecuteInput,
|
||||
output: ToolExecuteOutput,
|
||||
) => {
|
||||
const { tool, sessionID } = input;
|
||||
|
||||
const agent = getSessionAgent(sessionID);
|
||||
if (agent && !isOrchestratorAgent(agent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toolLower = tool.toLowerCase();
|
||||
|
||||
if (AGENT_TOOLS.has(toolLower)) {
|
||||
markAgentUsed(sessionID);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TARGET_TOOLS.has(toolLower)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = getOrCreateState(sessionID);
|
||||
|
||||
if (state.agentUsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
output.output += REMINDER_MESSAGE;
|
||||
state.reminderCount++;
|
||||
state.updatedAt = Date.now();
|
||||
saveAgentUsageState(state);
|
||||
};
|
||||
|
||||
const eventHandler = async ({ event }: EventInput) => {
|
||||
const props = event.properties as Record<string, unknown> | undefined;
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||
if (sessionInfo?.id) {
|
||||
resetState(sessionInfo.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID ??
|
||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
|
||||
if (sessionID) {
|
||||
resetState(sessionID);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
"tool.execute.after": toolExecuteAfter,
|
||||
event: eventHandler,
|
||||
};
|
||||
}
|
||||
@@ -1,109 +1 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
import {
|
||||
loadAgentUsageState,
|
||||
saveAgentUsageState,
|
||||
clearAgentUsageState,
|
||||
} from "./storage";
|
||||
import { TARGET_TOOLS, AGENT_TOOLS, REMINDER_MESSAGE } from "./constants";
|
||||
import type { AgentUsageState } from "./types";
|
||||
|
||||
interface ToolExecuteInput {
|
||||
tool: string;
|
||||
sessionID: string;
|
||||
callID: string;
|
||||
}
|
||||
|
||||
interface ToolExecuteOutput {
|
||||
title: string;
|
||||
output: string;
|
||||
metadata: unknown;
|
||||
}
|
||||
|
||||
interface EventInput {
|
||||
event: {
|
||||
type: string;
|
||||
properties?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export function createAgentUsageReminderHook(_ctx: PluginInput) {
|
||||
const sessionStates = new Map<string, AgentUsageState>();
|
||||
|
||||
function getOrCreateState(sessionID: string): AgentUsageState {
|
||||
if (!sessionStates.has(sessionID)) {
|
||||
const persisted = loadAgentUsageState(sessionID);
|
||||
const state: AgentUsageState = persisted ?? {
|
||||
sessionID,
|
||||
agentUsed: false,
|
||||
reminderCount: 0,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
sessionStates.set(sessionID, state);
|
||||
}
|
||||
return sessionStates.get(sessionID)!;
|
||||
}
|
||||
|
||||
function markAgentUsed(sessionID: string): void {
|
||||
const state = getOrCreateState(sessionID);
|
||||
state.agentUsed = true;
|
||||
state.updatedAt = Date.now();
|
||||
saveAgentUsageState(state);
|
||||
}
|
||||
|
||||
function resetState(sessionID: string): void {
|
||||
sessionStates.delete(sessionID);
|
||||
clearAgentUsageState(sessionID);
|
||||
}
|
||||
|
||||
const toolExecuteAfter = async (
|
||||
input: ToolExecuteInput,
|
||||
output: ToolExecuteOutput,
|
||||
) => {
|
||||
const { tool, sessionID } = input;
|
||||
const toolLower = tool.toLowerCase();
|
||||
|
||||
if (AGENT_TOOLS.has(toolLower)) {
|
||||
markAgentUsed(sessionID);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TARGET_TOOLS.has(toolLower)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = getOrCreateState(sessionID);
|
||||
|
||||
if (state.agentUsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
output.output += REMINDER_MESSAGE;
|
||||
state.reminderCount++;
|
||||
state.updatedAt = Date.now();
|
||||
saveAgentUsageState(state);
|
||||
};
|
||||
|
||||
const eventHandler = async ({ event }: EventInput) => {
|
||||
const props = event.properties as Record<string, unknown> | undefined;
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||
if (sessionInfo?.id) {
|
||||
resetState(sessionInfo.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID ??
|
||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
|
||||
if (sessionID) {
|
||||
resetState(sessionID);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
"tool.execute.after": toolExecuteAfter,
|
||||
event: eventHandler,
|
||||
};
|
||||
}
|
||||
export { createAgentUsageReminderHook } from "./hook";
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# src/hooks/anthropic-context-window-limit-recovery/ — Multi-Strategy Context Recovery
|
||||
|
||||
**Generated:** 2026-03-06
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
31 files (~2232 LOC). Most complex hook. Recovers from context window limit errors via multiple strategies applied in sequence.
|
||||
|
||||
## RECOVERY STRATEGIES (in priority order)
|
||||
|
||||
| Strategy | File | Mechanism |
|
||||
|----------|------|-----------|
|
||||
| **Empty content recovery** | `empty-content-recovery.ts` | Handle empty/null content blocks in messages |
|
||||
| **Deduplication** | `deduplication-recovery.ts` | Remove duplicate tool results from context |
|
||||
| **Target-token truncation** | `target-token-truncation.ts` | Truncate largest tool outputs to fit target ratio |
|
||||
| **Aggressive truncation** | `aggressive-truncation-strategy.ts` | Last-resort truncation with minimal output preservation |
|
||||
| **Summarize retry** | `summarize-retry-strategy.ts` | Compaction + summarization then retry |
|
||||
|
||||
## KEY FILES
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `recovery-hook.ts` | Main hook entry — `session.error` handler, strategy orchestration |
|
||||
| `executor.ts` | Execute recovery strategies in sequence |
|
||||
| `parser.ts` | Parse Anthropic token limit error messages |
|
||||
| `state.ts` | `AutoCompactState` — per-session retry/truncation tracking |
|
||||
| `types.ts` | `ParsedTokenLimitError`, `RetryState`, `TruncateState`, config constants |
|
||||
| `storage.ts` | Persist tool results for later truncation |
|
||||
| `tool-result-storage.ts` | Store/retrieve individual tool call results |
|
||||
| `message-builder.ts` | Build retry messages after recovery |
|
||||
|
||||
## RETRY CONFIG
|
||||
|
||||
- Max attempts: 2
|
||||
- Initial delay: 2s, backoff ×2, max 30s
|
||||
- Max truncation attempts: 20
|
||||
- Target token ratio: 0.5 (truncate to 50% of limit)
|
||||
- Chars per token estimate: 4
|
||||
|
||||
## PRUNING SYSTEM
|
||||
|
||||
`pruning-*.ts` files handle intelligent output pruning:
|
||||
- `pruning-deduplication.ts` — Remove duplicate content across tool results
|
||||
- `pruning-tool-output-truncation.ts` — Truncate oversized tool outputs
|
||||
- `pruning-types.ts` — Pruning-specific type definitions
|
||||
|
||||
## SDK VARIANTS
|
||||
|
||||
`empty-content-recovery-sdk.ts` and `tool-result-storage-sdk.ts` provide SDK-based implementations for OpenCode client interactions.
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { AutoCompactState } from "./types"
|
||||
import { TRUNCATE_CONFIG } from "./types"
|
||||
import { truncateUntilTargetTokens } from "./storage"
|
||||
import type { Client } from "./client"
|
||||
import { clearSessionState } from "./state"
|
||||
import { formatBytes } from "./message-builder"
|
||||
import { log } from "../../shared/logger"
|
||||
import { resolveInheritedPromptTools } from "../../shared"
|
||||
|
||||
export async function runAggressiveTruncationStrategy(params: {
|
||||
sessionID: string
|
||||
autoCompactState: AutoCompactState
|
||||
client: Client
|
||||
directory: string
|
||||
truncateAttempt: number
|
||||
currentTokens: number
|
||||
maxTokens: number
|
||||
}): Promise<{ handled: boolean; nextTruncateAttempt: number }> {
|
||||
if (params.truncateAttempt >= TRUNCATE_CONFIG.maxTruncateAttempts) {
|
||||
return { handled: false, nextTruncateAttempt: params.truncateAttempt }
|
||||
}
|
||||
|
||||
log("[auto-compact] PHASE 2: aggressive truncation triggered", {
|
||||
currentTokens: params.currentTokens,
|
||||
maxTokens: params.maxTokens,
|
||||
targetRatio: TRUNCATE_CONFIG.targetTokenRatio,
|
||||
})
|
||||
|
||||
const aggressiveResult = await truncateUntilTargetTokens(
|
||||
params.sessionID,
|
||||
params.currentTokens,
|
||||
params.maxTokens,
|
||||
TRUNCATE_CONFIG.targetTokenRatio,
|
||||
TRUNCATE_CONFIG.charsPerToken,
|
||||
params.client,
|
||||
)
|
||||
|
||||
if (aggressiveResult.truncatedCount <= 0) {
|
||||
return { handled: false, nextTruncateAttempt: params.truncateAttempt }
|
||||
}
|
||||
|
||||
const nextTruncateAttempt = params.truncateAttempt + aggressiveResult.truncatedCount
|
||||
const toolNames = aggressiveResult.truncatedTools.map((t) => t.toolName).join(", ")
|
||||
const statusMsg = aggressiveResult.sufficient
|
||||
? `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)})`
|
||||
: `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)}) - continuing to summarize...`
|
||||
|
||||
await params.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: aggressiveResult.sufficient ? "Truncation Complete" : "Partial Truncation",
|
||||
message: `${statusMsg}: ${toolNames}`,
|
||||
variant: aggressiveResult.sufficient ? "success" : "warning",
|
||||
duration: 4000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
log("[auto-compact] aggressive truncation completed", aggressiveResult)
|
||||
|
||||
if (aggressiveResult.sufficient) {
|
||||
clearSessionState(params.autoCompactState, params.sessionID)
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const inheritedTools = resolveInheritedPromptTools(params.sessionID)
|
||||
await params.client.session.promptAsync({
|
||||
path: { id: params.sessionID },
|
||||
body: {
|
||||
auto: true,
|
||||
...(inheritedTools ? { tools: inheritedTools } : {}),
|
||||
} as never,
|
||||
query: { directory: params.directory },
|
||||
})
|
||||
} catch {}
|
||||
}, 500)
|
||||
|
||||
return { handled: true, nextTruncateAttempt }
|
||||
}
|
||||
|
||||
log("[auto-compact] truncation insufficient, falling through to summarize", {
|
||||
sessionID: params.sessionID,
|
||||
truncatedCount: aggressiveResult.truncatedCount,
|
||||
sufficient: aggressiveResult.sufficient,
|
||||
})
|
||||
|
||||
return { handled: false, nextTruncateAttempt }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
export type Client = PluginInput["client"] & {
|
||||
session: {
|
||||
promptAsync: (opts: {
|
||||
path: { id: string }
|
||||
body: { parts: Array<{ type: string; text: string }> }
|
||||
query: { directory: string }
|
||||
}) => Promise<unknown>
|
||||
}
|
||||
tui: {
|
||||
showToast: (opts: {
|
||||
body: {
|
||||
title: string
|
||||
message: string
|
||||
variant: string
|
||||
duration: number
|
||||
}
|
||||
}) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { ParsedTokenLimitError } from "./types"
|
||||
import type { ExperimentalConfig } from "../../config"
|
||||
import type { DeduplicationConfig } from "./pruning-deduplication"
|
||||
import type { PruningState } from "./pruning-types"
|
||||
import { executeDeduplication } from "./pruning-deduplication"
|
||||
import { truncateToolOutputsByCallId } from "./pruning-tool-output-truncation"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
function createPruningState(): PruningState {
|
||||
return {
|
||||
toolIdsToPrune: new Set<string>(),
|
||||
currentTurn: 0,
|
||||
fileOperations: new Map(),
|
||||
toolSignatures: new Map(),
|
||||
erroredTools: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
function isPromptTooLongError(parsed: ParsedTokenLimitError): boolean {
|
||||
return !parsed.errorType.toLowerCase().includes("non-empty content")
|
||||
}
|
||||
|
||||
function getDeduplicationPlan(
|
||||
experimental?: ExperimentalConfig,
|
||||
): { config: DeduplicationConfig; protectedTools: Set<string> } | null {
|
||||
const pruningConfig = experimental?.dynamic_context_pruning
|
||||
if (!pruningConfig?.enabled) return null
|
||||
|
||||
const deduplicationEnabled = pruningConfig.strategies?.deduplication?.enabled
|
||||
if (deduplicationEnabled === false) return null
|
||||
|
||||
const protectedTools = new Set(pruningConfig.protected_tools ?? [])
|
||||
return {
|
||||
config: {
|
||||
enabled: true,
|
||||
protectedTools: pruningConfig.protected_tools ?? [],
|
||||
},
|
||||
protectedTools,
|
||||
}
|
||||
}
|
||||
|
||||
export async function attemptDeduplicationRecovery(
|
||||
sessionID: string,
|
||||
parsed: ParsedTokenLimitError,
|
||||
experimental: ExperimentalConfig | undefined,
|
||||
client?: OpencodeClient,
|
||||
): Promise<void> {
|
||||
if (!isPromptTooLongError(parsed)) return
|
||||
|
||||
const plan = getDeduplicationPlan(experimental)
|
||||
if (!plan) return
|
||||
|
||||
const pruningState = createPruningState()
|
||||
const prunedCount = await executeDeduplication(
|
||||
sessionID,
|
||||
pruningState,
|
||||
plan.config,
|
||||
plan.protectedTools,
|
||||
client,
|
||||
)
|
||||
const { truncatedCount } = await truncateToolOutputsByCallId(
|
||||
sessionID,
|
||||
pruningState.toolIdsToPrune,
|
||||
client,
|
||||
)
|
||||
|
||||
if (prunedCount > 0 || truncatedCount > 0) {
|
||||
log("[auto-compact] deduplication recovery applied", {
|
||||
sessionID,
|
||||
prunedCount,
|
||||
truncatedCount,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, it, expect, mock, beforeEach } from "bun:test"
|
||||
import { fixEmptyMessagesWithSDK } from "./empty-content-recovery-sdk"
|
||||
|
||||
const mockReplaceEmptyTextParts = mock(() => Promise.resolve(false))
|
||||
const mockInjectTextPart = mock(() => Promise.resolve(false))
|
||||
|
||||
mock.module("../session-recovery/storage/empty-text", () => ({
|
||||
replaceEmptyTextPartsAsync: mockReplaceEmptyTextParts,
|
||||
}))
|
||||
mock.module("../session-recovery/storage/text-part-injector", () => ({
|
||||
injectTextPartAsync: mockInjectTextPart,
|
||||
}))
|
||||
|
||||
function createMockClient(messages: Array<{ info?: { id?: string }; parts?: Array<{ type?: string; text?: string }> }>) {
|
||||
return {
|
||||
session: {
|
||||
messages: mock(() => Promise.resolve({ data: messages })),
|
||||
},
|
||||
} as never
|
||||
}
|
||||
|
||||
describe("fixEmptyMessagesWithSDK", () => {
|
||||
beforeEach(() => {
|
||||
mockReplaceEmptyTextParts.mockReset()
|
||||
mockInjectTextPart.mockReset()
|
||||
mockReplaceEmptyTextParts.mockReturnValue(Promise.resolve(false))
|
||||
mockInjectTextPart.mockReturnValue(Promise.resolve(false))
|
||||
})
|
||||
|
||||
it("returns fixed=false when no empty messages exist", async () => {
|
||||
//#given
|
||||
const client = createMockClient([
|
||||
{ info: { id: "msg_1" }, parts: [{ type: "text", text: "Hello" }] },
|
||||
])
|
||||
|
||||
//#when
|
||||
const result = await fixEmptyMessagesWithSDK({
|
||||
sessionID: "ses_1",
|
||||
client,
|
||||
placeholderText: "[recovered]",
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(result.fixed).toBe(false)
|
||||
expect(result.fixedMessageIds).toEqual([])
|
||||
expect(result.scannedEmptyCount).toBe(0)
|
||||
})
|
||||
|
||||
it("fixes empty message via replace when scanning all", async () => {
|
||||
//#given
|
||||
const client = createMockClient([
|
||||
{ info: { id: "msg_1" }, parts: [{ type: "text", text: "" }] },
|
||||
])
|
||||
mockReplaceEmptyTextParts.mockReturnValue(Promise.resolve(true))
|
||||
|
||||
//#when
|
||||
const result = await fixEmptyMessagesWithSDK({
|
||||
sessionID: "ses_1",
|
||||
client,
|
||||
placeholderText: "[recovered]",
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(result.fixed).toBe(true)
|
||||
expect(result.fixedMessageIds).toContain("msg_1")
|
||||
expect(result.scannedEmptyCount).toBe(1)
|
||||
})
|
||||
|
||||
it("falls back to inject when replace fails", async () => {
|
||||
//#given
|
||||
const client = createMockClient([
|
||||
{ info: { id: "msg_1" }, parts: [] },
|
||||
])
|
||||
mockReplaceEmptyTextParts.mockReturnValue(Promise.resolve(false))
|
||||
mockInjectTextPart.mockReturnValue(Promise.resolve(true))
|
||||
|
||||
//#when
|
||||
const result = await fixEmptyMessagesWithSDK({
|
||||
sessionID: "ses_1",
|
||||
client,
|
||||
placeholderText: "[recovered]",
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(result.fixed).toBe(true)
|
||||
expect(result.fixedMessageIds).toContain("msg_1")
|
||||
})
|
||||
|
||||
it("fixes target message by index when provided", async () => {
|
||||
//#given
|
||||
const client = createMockClient([
|
||||
{ info: { id: "msg_0" }, parts: [{ type: "text", text: "ok" }] },
|
||||
{ info: { id: "msg_1" }, parts: [] },
|
||||
])
|
||||
mockReplaceEmptyTextParts.mockReturnValue(Promise.resolve(true))
|
||||
|
||||
//#when
|
||||
const result = await fixEmptyMessagesWithSDK({
|
||||
sessionID: "ses_1",
|
||||
client,
|
||||
placeholderText: "[recovered]",
|
||||
messageIndex: 1,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(result.fixed).toBe(true)
|
||||
expect(result.fixedMessageIds).toContain("msg_1")
|
||||
expect(result.scannedEmptyCount).toBe(0)
|
||||
})
|
||||
|
||||
it("skips messages without info.id", async () => {
|
||||
//#given
|
||||
const client = createMockClient([
|
||||
{ parts: [] },
|
||||
{ info: {}, parts: [] },
|
||||
])
|
||||
|
||||
//#when
|
||||
const result = await fixEmptyMessagesWithSDK({
|
||||
sessionID: "ses_1",
|
||||
client,
|
||||
placeholderText: "[recovered]",
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(result.fixed).toBe(false)
|
||||
expect(result.scannedEmptyCount).toBe(0)
|
||||
})
|
||||
|
||||
it("treats thinking-only messages as empty", async () => {
|
||||
//#given
|
||||
const client = createMockClient([
|
||||
{ info: { id: "msg_1" }, parts: [{ type: "thinking", text: "hmm" }] },
|
||||
])
|
||||
mockReplaceEmptyTextParts.mockReturnValue(Promise.resolve(true))
|
||||
|
||||
//#when
|
||||
const result = await fixEmptyMessagesWithSDK({
|
||||
sessionID: "ses_1",
|
||||
client,
|
||||
placeholderText: "[recovered]",
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(result.fixed).toBe(true)
|
||||
expect(result.fixedMessageIds).toContain("msg_1")
|
||||
})
|
||||
|
||||
it("treats tool_use messages as non-empty", async () => {
|
||||
//#given
|
||||
const client = createMockClient([
|
||||
{ info: { id: "msg_1" }, parts: [{ type: "tool_use" }] },
|
||||
])
|
||||
|
||||
//#when
|
||||
const result = await fixEmptyMessagesWithSDK({
|
||||
sessionID: "ses_1",
|
||||
client,
|
||||
placeholderText: "[recovered]",
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(result.fixed).toBe(false)
|
||||
expect(result.scannedEmptyCount).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,191 @@
|
||||
import { replaceEmptyTextPartsAsync } from "../session-recovery/storage/empty-text"
|
||||
import { injectTextPartAsync } from "../session-recovery/storage/text-part-injector"
|
||||
import type { Client } from "./client"
|
||||
|
||||
interface SDKPart {
|
||||
id?: string
|
||||
type?: string
|
||||
text?: string
|
||||
}
|
||||
|
||||
interface SDKMessage {
|
||||
info?: { id?: string }
|
||||
parts?: SDKPart[]
|
||||
}
|
||||
|
||||
const IGNORE_TYPES = new Set(["thinking", "redacted_thinking", "meta"])
|
||||
const TOOL_TYPES = new Set(["tool", "tool_use", "tool_result"])
|
||||
|
||||
function messageHasContentFromSDK(message: SDKMessage): boolean {
|
||||
const parts = message.parts
|
||||
if (!parts || parts.length === 0) return false
|
||||
|
||||
for (const part of parts) {
|
||||
const type = part.type
|
||||
if (!type) continue
|
||||
if (IGNORE_TYPES.has(type)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (type === "text") {
|
||||
if (part.text?.trim()) return true
|
||||
continue
|
||||
}
|
||||
|
||||
if (TOOL_TYPES.has(type)) return true
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Messages with only thinking/meta parts are treated as empty
|
||||
// to align with file-based logic (messageHasContent)
|
||||
return false
|
||||
}
|
||||
|
||||
function getSdkMessages(response: unknown): SDKMessage[] {
|
||||
if (typeof response !== "object" || response === null) return []
|
||||
if (Array.isArray(response)) return response as SDKMessage[]
|
||||
const record = response as Record<string, unknown>
|
||||
const data = record["data"]
|
||||
if (Array.isArray(data)) return data as SDKMessage[]
|
||||
return Array.isArray(record) ? (record as SDKMessage[]) : []
|
||||
}
|
||||
|
||||
async function findEmptyMessagesFromSDK(client: Client, sessionID: string): Promise<string[]> {
|
||||
try {
|
||||
const response = await client.session.messages({ path: { id: sessionID } })
|
||||
const messages = getSdkMessages(response)
|
||||
|
||||
const emptyIds: string[] = []
|
||||
for (const message of messages) {
|
||||
const messageID = message.info?.id
|
||||
if (!messageID) continue
|
||||
if (!messageHasContentFromSDK(message)) {
|
||||
emptyIds.push(messageID)
|
||||
}
|
||||
}
|
||||
|
||||
return emptyIds
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function findEmptyMessageByIndexFromSDK(
|
||||
client: Client,
|
||||
sessionID: string,
|
||||
targetIndex: number,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const response = await client.session.messages({ path: { id: sessionID } })
|
||||
const messages = getSdkMessages(response)
|
||||
|
||||
const indicesToTry = [
|
||||
targetIndex,
|
||||
targetIndex - 1,
|
||||
targetIndex + 1,
|
||||
targetIndex - 2,
|
||||
targetIndex + 2,
|
||||
targetIndex - 3,
|
||||
targetIndex - 4,
|
||||
targetIndex - 5,
|
||||
]
|
||||
|
||||
for (const index of indicesToTry) {
|
||||
if (index < 0 || index >= messages.length) continue
|
||||
|
||||
const targetMessage = messages[index]
|
||||
const targetMessageId = targetMessage?.info?.id
|
||||
if (!targetMessageId) continue
|
||||
|
||||
if (!messageHasContentFromSDK(targetMessage)) {
|
||||
return targetMessageId
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function fixEmptyMessagesWithSDK(params: {
|
||||
sessionID: string
|
||||
client: Client
|
||||
placeholderText: string
|
||||
messageIndex?: number
|
||||
}): Promise<{ fixed: boolean; fixedMessageIds: string[]; scannedEmptyCount: number }> {
|
||||
let fixed = false
|
||||
const fixedMessageIds: string[] = []
|
||||
|
||||
if (params.messageIndex !== undefined) {
|
||||
const targetMessageId = await findEmptyMessageByIndexFromSDK(
|
||||
params.client,
|
||||
params.sessionID,
|
||||
params.messageIndex,
|
||||
)
|
||||
|
||||
if (targetMessageId) {
|
||||
const replaced = await replaceEmptyTextPartsAsync(
|
||||
params.client,
|
||||
params.sessionID,
|
||||
targetMessageId,
|
||||
params.placeholderText,
|
||||
)
|
||||
|
||||
if (replaced) {
|
||||
fixed = true
|
||||
fixedMessageIds.push(targetMessageId)
|
||||
} else {
|
||||
const injected = await injectTextPartAsync(
|
||||
params.client,
|
||||
params.sessionID,
|
||||
targetMessageId,
|
||||
params.placeholderText,
|
||||
)
|
||||
|
||||
if (injected) {
|
||||
fixed = true
|
||||
fixedMessageIds.push(targetMessageId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fixed) {
|
||||
return { fixed, fixedMessageIds, scannedEmptyCount: 0 }
|
||||
}
|
||||
|
||||
const emptyMessageIds = await findEmptyMessagesFromSDK(params.client, params.sessionID)
|
||||
if (emptyMessageIds.length === 0) {
|
||||
return { fixed: false, fixedMessageIds: [], scannedEmptyCount: 0 }
|
||||
}
|
||||
|
||||
for (const messageID of emptyMessageIds) {
|
||||
const replaced = await replaceEmptyTextPartsAsync(
|
||||
params.client,
|
||||
params.sessionID,
|
||||
messageID,
|
||||
params.placeholderText,
|
||||
)
|
||||
|
||||
if (replaced) {
|
||||
fixed = true
|
||||
fixedMessageIds.push(messageID)
|
||||
} else {
|
||||
const injected = await injectTextPartAsync(
|
||||
params.client,
|
||||
params.sessionID,
|
||||
messageID,
|
||||
params.placeholderText,
|
||||
)
|
||||
|
||||
if (injected) {
|
||||
fixed = true
|
||||
fixedMessageIds.push(messageID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { fixed, fixedMessageIds, scannedEmptyCount: emptyMessageIds.length }
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
findEmptyMessages,
|
||||
findEmptyMessageByIndex,
|
||||
injectTextPart,
|
||||
replaceEmptyTextParts,
|
||||
} from "../session-recovery/storage"
|
||||
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
|
||||
import type { AutoCompactState } from "./types"
|
||||
import type { Client } from "./client"
|
||||
import { PLACEHOLDER_TEXT } from "./message-builder"
|
||||
import { incrementEmptyContentAttempt } from "./state"
|
||||
import { fixEmptyMessagesWithSDK } from "./empty-content-recovery-sdk"
|
||||
|
||||
export async function fixEmptyMessages(params: {
|
||||
sessionID: string
|
||||
autoCompactState: AutoCompactState
|
||||
client: Client
|
||||
messageIndex?: number
|
||||
}): Promise<boolean> {
|
||||
incrementEmptyContentAttempt(params.autoCompactState, params.sessionID)
|
||||
|
||||
let fixed = false
|
||||
const fixedMessageIds: string[] = []
|
||||
|
||||
if (isSqliteBackend()) {
|
||||
const result = await fixEmptyMessagesWithSDK({
|
||||
sessionID: params.sessionID,
|
||||
client: params.client,
|
||||
placeholderText: PLACEHOLDER_TEXT,
|
||||
messageIndex: params.messageIndex,
|
||||
})
|
||||
|
||||
if (!result.fixed && result.scannedEmptyCount === 0) {
|
||||
await params.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Empty Content Error",
|
||||
message: "No empty messages found in storage. Cannot auto-recover.",
|
||||
variant: "error",
|
||||
duration: 5000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
return false
|
||||
}
|
||||
|
||||
if (result.fixed) {
|
||||
await params.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Session Recovery",
|
||||
message: `Fixed ${result.fixedMessageIds.length} empty message(s). Retrying...`,
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
return result.fixed
|
||||
}
|
||||
|
||||
if (params.messageIndex !== undefined) {
|
||||
const targetMessageId = findEmptyMessageByIndex(params.sessionID, params.messageIndex)
|
||||
if (targetMessageId) {
|
||||
const replaced = replaceEmptyTextParts(targetMessageId, PLACEHOLDER_TEXT)
|
||||
if (replaced) {
|
||||
fixed = true
|
||||
fixedMessageIds.push(targetMessageId)
|
||||
} else {
|
||||
const injected = injectTextPart(params.sessionID, targetMessageId, PLACEHOLDER_TEXT)
|
||||
if (injected) {
|
||||
fixed = true
|
||||
fixedMessageIds.push(targetMessageId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!fixed) {
|
||||
const emptyMessageIds = findEmptyMessages(params.sessionID)
|
||||
if (emptyMessageIds.length === 0) {
|
||||
await params.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Empty Content Error",
|
||||
message: "No empty messages found in storage. Cannot auto-recover.",
|
||||
variant: "error",
|
||||
duration: 5000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
return false
|
||||
}
|
||||
|
||||
for (const messageID of emptyMessageIds) {
|
||||
const replaced = replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT)
|
||||
if (replaced) {
|
||||
fixed = true
|
||||
fixedMessageIds.push(messageID)
|
||||
} else {
|
||||
const injected = injectTextPart(params.sessionID, messageID, PLACEHOLDER_TEXT)
|
||||
if (injected) {
|
||||
fixed = true
|
||||
fixedMessageIds.push(messageID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fixed) {
|
||||
await params.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Session Recovery",
|
||||
message: `Fixed ${fixedMessageIds.length} empty message(s). Retrying...`,
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
return fixed
|
||||
}
|
||||
@@ -1,17 +1,91 @@
|
||||
import { describe, test, expect, mock, beforeEach, spyOn } from "bun:test"
|
||||
/// <reference types="bun-types" />
|
||||
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import { executeCompact } from "./executor"
|
||||
import type { AutoCompactState } from "./types"
|
||||
import * as storage from "./storage"
|
||||
import * as recoveryStrategy from "./recovery-strategy"
|
||||
import * as messagesReader from "../session-recovery/storage/messages-reader"
|
||||
|
||||
type TimerCallback = (...args: any[]) => void
|
||||
|
||||
interface FakeTimeouts {
|
||||
advanceBy: (ms: number) => Promise<void>
|
||||
restore: () => void
|
||||
}
|
||||
|
||||
// Capture the real implementations at module load time, before any test can patch them.
|
||||
// This ensures restore() always returns to the true originals regardless of test execution order.
|
||||
const TRUE_ORIGINAL_SET_TIMEOUT = globalThis.setTimeout
|
||||
const TRUE_ORIGINAL_CLEAR_TIMEOUT = globalThis.clearTimeout
|
||||
|
||||
function createFakeTimeouts(): FakeTimeouts {
|
||||
let now = 0
|
||||
let nextId = 1
|
||||
const timers = new Map<number, { id: number; time: number; callback: TimerCallback; args: any[] }>()
|
||||
const cleared = new Set<number>()
|
||||
|
||||
const normalizeDelay = (delay?: number) => {
|
||||
if (typeof delay !== "number" || !Number.isFinite(delay)) return 0
|
||||
return delay < 0 ? 0 : delay
|
||||
}
|
||||
|
||||
globalThis.setTimeout = ((callback: TimerCallback, delay?: number, ...args: any[]) => {
|
||||
const id = nextId++
|
||||
timers.set(id, {
|
||||
id,
|
||||
time: now + normalizeDelay(delay),
|
||||
callback,
|
||||
args,
|
||||
})
|
||||
return id as unknown as ReturnType<typeof setTimeout>
|
||||
}) as typeof setTimeout
|
||||
|
||||
globalThis.clearTimeout = ((id?: number) => {
|
||||
if (typeof id !== "number") return
|
||||
cleared.add(id)
|
||||
timers.delete(id)
|
||||
}) as typeof clearTimeout
|
||||
|
||||
const advanceBy = async (ms: number) => {
|
||||
const target = now + Math.max(0, ms)
|
||||
while (true) {
|
||||
let next: { id: number; time: number; callback: TimerCallback; args: any[] } | undefined
|
||||
for (const timer of timers.values()) {
|
||||
if (timer.time <= target && (!next || timer.time < next.time)) {
|
||||
next = timer
|
||||
}
|
||||
}
|
||||
if (!next) break
|
||||
|
||||
now = next.time
|
||||
timers.delete(next.id)
|
||||
if (!cleared.has(next.id)) {
|
||||
next.callback(...next.args)
|
||||
}
|
||||
cleared.delete(next.id)
|
||||
await Promise.resolve()
|
||||
}
|
||||
now = target
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
const restore = () => {
|
||||
globalThis.setTimeout = TRUE_ORIGINAL_SET_TIMEOUT
|
||||
globalThis.clearTimeout = TRUE_ORIGINAL_CLEAR_TIMEOUT
|
||||
}
|
||||
|
||||
return { advanceBy, restore }
|
||||
}
|
||||
|
||||
describe("executeCompact lock management", () => {
|
||||
let autoCompactState: AutoCompactState
|
||||
let mockClient: any
|
||||
let fakeTimeouts: FakeTimeouts
|
||||
const sessionID = "test-session-123"
|
||||
const directory = "/test/dir"
|
||||
const msg = { providerID: "anthropic", modelID: "claude-opus-4-5" }
|
||||
const msg = { providerID: "anthropic", modelID: "claude-opus-4-6" }
|
||||
|
||||
beforeEach(() => {
|
||||
// #given: Fresh state for each test
|
||||
// given: Fresh state for each test
|
||||
autoCompactState = {
|
||||
pendingCompact: new Set<string>(),
|
||||
errorDataBySession: new Map(),
|
||||
@@ -26,31 +100,37 @@ describe("executeCompact lock management", () => {
|
||||
messages: mock(() => Promise.resolve({ data: [] })),
|
||||
summarize: mock(() => Promise.resolve()),
|
||||
revert: mock(() => Promise.resolve()),
|
||||
prompt_async: mock(() => Promise.resolve()),
|
||||
promptAsync: mock(() => Promise.resolve()),
|
||||
},
|
||||
tui: {
|
||||
showToast: mock(() => Promise.resolve()),
|
||||
},
|
||||
}
|
||||
|
||||
fakeTimeouts = createFakeTimeouts()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fakeTimeouts.restore()
|
||||
})
|
||||
|
||||
test("clears lock on successful summarize completion", async () => {
|
||||
// #given: Valid session with providerID/modelID
|
||||
// given: Valid session with providerID/modelID
|
||||
autoCompactState.errorDataBySession.set(sessionID, {
|
||||
errorType: "token_limit",
|
||||
currentTokens: 100000,
|
||||
maxTokens: 200000,
|
||||
})
|
||||
|
||||
// #when: Execute compaction successfully
|
||||
// when: Execute compaction successfully
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||
|
||||
// #then: Lock should be cleared
|
||||
// then: Lock should be cleared
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||
})
|
||||
|
||||
test("clears lock when summarize throws exception", async () => {
|
||||
// #given: Summarize will fail
|
||||
// given: Summarize will fail
|
||||
mockClient.session.summarize = mock(() =>
|
||||
Promise.reject(new Error("Network timeout")),
|
||||
)
|
||||
@@ -60,21 +140,21 @@ describe("executeCompact lock management", () => {
|
||||
maxTokens: 200000,
|
||||
})
|
||||
|
||||
// #when: Execute compaction
|
||||
// when: Execute compaction
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||
|
||||
// #then: Lock should still be cleared despite exception
|
||||
// then: Lock should still be cleared despite exception
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||
})
|
||||
|
||||
test("shows toast when lock already held", async () => {
|
||||
// #given: Lock already held
|
||||
// given: Lock already held
|
||||
autoCompactState.compactionInProgress.add(sessionID)
|
||||
|
||||
// #when: Try to execute compaction
|
||||
// when: Try to execute compaction
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||
|
||||
// #then: Toast should be shown with warning message
|
||||
// then: Toast should be shown with warning message
|
||||
expect(mockClient.tui.showToast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
@@ -85,12 +165,13 @@ describe("executeCompact lock management", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
// #then: compactionInProgress should still have the lock
|
||||
// then: compactionInProgress should still have the lock
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(true)
|
||||
})
|
||||
|
||||
test("clears lock when fixEmptyMessages path executes", async () => {
|
||||
// #given: Empty content error scenario
|
||||
//#given - Empty content error scenario with no messages in storage
|
||||
const readMessagesSpy = spyOn(messagesReader, "readMessages").mockReturnValue([])
|
||||
autoCompactState.errorDataBySession.set(sessionID, {
|
||||
errorType: "non-empty content required",
|
||||
messageIndex: 0,
|
||||
@@ -98,16 +179,17 @@ describe("executeCompact lock management", () => {
|
||||
maxTokens: 200000,
|
||||
})
|
||||
|
||||
// #when: Execute compaction (fixEmptyMessages will be called)
|
||||
//#when - Execute compaction (fixEmptyMessages will be called)
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||
|
||||
// #then: Lock should be cleared
|
||||
//#then - Lock should be cleared
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||
readMessagesSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("clears lock when truncation is sufficient", async () => {
|
||||
// #given: Aggressive truncation scenario with sufficient truncation
|
||||
// This test verifies the early return path in aggressive truncation
|
||||
//#given - Aggressive truncation scenario with no messages in storage
|
||||
const readMessagesSpy = spyOn(messagesReader, "readMessages").mockReturnValue([])
|
||||
autoCompactState.errorDataBySession.set(sessionID, {
|
||||
errorType: "token_limit",
|
||||
currentTokens: 250000,
|
||||
@@ -119,7 +201,7 @@ describe("executeCompact lock management", () => {
|
||||
aggressive_truncation: true,
|
||||
}
|
||||
|
||||
// #when: Execute compaction with experimental flag
|
||||
//#when - Execute compaction with experimental flag
|
||||
await executeCompact(
|
||||
sessionID,
|
||||
msg,
|
||||
@@ -129,30 +211,31 @@ describe("executeCompact lock management", () => {
|
||||
experimental,
|
||||
)
|
||||
|
||||
// #then: Lock should be cleared even on early return
|
||||
//#then - Lock should be cleared even on early return
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||
readMessagesSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("prevents concurrent compaction attempts", async () => {
|
||||
// #given: Lock already held (simpler test)
|
||||
// given: Lock already held (simpler test)
|
||||
autoCompactState.compactionInProgress.add(sessionID)
|
||||
|
||||
// #when: Try to execute compaction while lock is held
|
||||
// when: Try to execute compaction while lock is held
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||
|
||||
// #then: Toast should be shown
|
||||
// then: Toast should be shown
|
||||
const toastCalls = (mockClient.tui.showToast as any).mock.calls
|
||||
const blockedToast = toastCalls.find(
|
||||
(call: any) => call[0]?.body?.title === "Compact In Progress",
|
||||
)
|
||||
expect(blockedToast).toBeDefined()
|
||||
|
||||
// #then: Lock should still be held (not cleared by blocked attempt)
|
||||
// then: Lock should still be held (not cleared by blocked attempt)
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(true)
|
||||
})
|
||||
|
||||
test("clears lock after max recovery attempts exhausted", async () => {
|
||||
// #given: All retry/revert attempts exhausted
|
||||
// given: All retry/revert attempts exhausted
|
||||
mockClient.session.messages = mock(() => Promise.resolve({ data: [] }))
|
||||
|
||||
// Max out all attempts
|
||||
@@ -169,22 +252,22 @@ describe("executeCompact lock management", () => {
|
||||
maxTokens: 200000,
|
||||
})
|
||||
|
||||
// #when: Execute compaction
|
||||
// when: Execute compaction
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||
|
||||
// #then: Should show failure toast
|
||||
// then: Should show failure toast
|
||||
const toastCalls = (mockClient.tui.showToast as any).mock.calls
|
||||
const failureToast = toastCalls.find(
|
||||
(call: any) => call[0]?.body?.title === "Auto Compact Failed",
|
||||
)
|
||||
expect(failureToast).toBeDefined()
|
||||
|
||||
// #then: Lock should still be cleared
|
||||
// then: Lock should still be cleared
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||
})
|
||||
|
||||
test("clears lock when client.tui.showToast throws", async () => {
|
||||
// #given: Toast will fail (this should never happen but testing robustness)
|
||||
// given: Toast will fail (this should never happen but testing robustness)
|
||||
mockClient.tui.showToast = mock(() =>
|
||||
Promise.reject(new Error("Toast failed")),
|
||||
)
|
||||
@@ -194,16 +277,16 @@ describe("executeCompact lock management", () => {
|
||||
maxTokens: 200000,
|
||||
})
|
||||
|
||||
// #when: Execute compaction
|
||||
// when: Execute compaction
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||
|
||||
// #then: Lock should be cleared even if toast fails
|
||||
// then: Lock should be cleared even if toast fails
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||
})
|
||||
|
||||
test("clears lock when prompt_async in continuation throws", async () => {
|
||||
// #given: prompt_async will fail during continuation
|
||||
mockClient.session.prompt_async = mock(() =>
|
||||
test("clears lock when promptAsync in continuation throws", async () => {
|
||||
// given: promptAsync will fail during continuation
|
||||
mockClient.session.promptAsync = mock(() =>
|
||||
Promise.reject(new Error("Prompt failed")),
|
||||
)
|
||||
autoCompactState.errorDataBySession.set(sessionID, {
|
||||
@@ -212,94 +295,97 @@ describe("executeCompact lock management", () => {
|
||||
maxTokens: 200000,
|
||||
})
|
||||
|
||||
// #when: Execute compaction
|
||||
// when: Execute compaction
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||
|
||||
// Wait for setTimeout callback
|
||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
||||
await fakeTimeouts.advanceBy(600)
|
||||
|
||||
// #then: Lock should be cleared
|
||||
// then: Lock should be cleared
|
||||
// The continuation happens in setTimeout, but lock is cleared in finally before that
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||
})
|
||||
|
||||
test("falls through to summarize when truncation is insufficient", async () => {
|
||||
// #given: Over token limit with truncation returning insufficient
|
||||
// given: Over token limit with truncation returning insufficient
|
||||
autoCompactState.errorDataBySession.set(sessionID, {
|
||||
errorType: "token_limit",
|
||||
currentTokens: 250000,
|
||||
maxTokens: 200000,
|
||||
})
|
||||
|
||||
const truncateSpy = spyOn(storage, "truncateUntilTargetTokens").mockReturnValue({
|
||||
success: true,
|
||||
sufficient: false,
|
||||
truncatedCount: 3,
|
||||
totalBytesRemoved: 10000,
|
||||
targetBytesToRemove: 50000,
|
||||
truncatedTools: [
|
||||
{ toolName: "Grep", originalSize: 5000 },
|
||||
{ toolName: "Read", originalSize: 3000 },
|
||||
{ toolName: "Bash", originalSize: 2000 },
|
||||
],
|
||||
})
|
||||
const truncateSpy = spyOn(
|
||||
recoveryStrategy,
|
||||
"runAggressiveTruncationStrategy",
|
||||
).mockImplementation(async (params) => ({
|
||||
handled: false,
|
||||
nextTruncateAttempt: params.truncateAttempt + 1,
|
||||
}))
|
||||
|
||||
// #when: Execute compaction
|
||||
// when: Execute compaction
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||
|
||||
// #then: Truncation was attempted
|
||||
// then: Truncation was attempted
|
||||
expect(truncateSpy).toHaveBeenCalled()
|
||||
|
||||
// #then: Summarize should be called (fall through from insufficient truncation)
|
||||
// then: Summarize should be called (fall through from insufficient truncation)
|
||||
expect(mockClient.session.summarize).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: { id: sessionID },
|
||||
body: { providerID: "anthropic", modelID: "claude-opus-4-5", auto: true },
|
||||
body: { providerID: "anthropic", modelID: "claude-opus-4-6", auto: true },
|
||||
}),
|
||||
)
|
||||
|
||||
// #then: Lock should be cleared
|
||||
// then: Lock should be cleared
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||
|
||||
truncateSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("does NOT call summarize when truncation is sufficient", async () => {
|
||||
// #given: Over token limit with truncation returning sufficient
|
||||
// given: Over token limit with truncation returning sufficient
|
||||
autoCompactState.errorDataBySession.set(sessionID, {
|
||||
errorType: "token_limit",
|
||||
currentTokens: 250000,
|
||||
maxTokens: 200000,
|
||||
})
|
||||
|
||||
const truncateSpy = spyOn(storage, "truncateUntilTargetTokens").mockReturnValue({
|
||||
success: true,
|
||||
sufficient: true,
|
||||
truncatedCount: 5,
|
||||
totalBytesRemoved: 60000,
|
||||
targetBytesToRemove: 50000,
|
||||
truncatedTools: [
|
||||
{ toolName: "Grep", originalSize: 30000 },
|
||||
{ toolName: "Read", originalSize: 30000 },
|
||||
],
|
||||
const truncateSpy = spyOn(
|
||||
recoveryStrategy,
|
||||
"runAggressiveTruncationStrategy",
|
||||
).mockImplementation(async (params) => {
|
||||
setTimeout(() => {
|
||||
void params.client.session
|
||||
.promptAsync({
|
||||
path: { id: params.sessionID },
|
||||
body: { auto: true } as never,
|
||||
query: { directory: params.directory },
|
||||
})
|
||||
.catch(() => {})
|
||||
}, 500)
|
||||
|
||||
return {
|
||||
handled: true,
|
||||
nextTruncateAttempt: params.truncateAttempt + 1,
|
||||
}
|
||||
})
|
||||
|
||||
// #when: Execute compaction
|
||||
// when: Execute compaction
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||
|
||||
// Wait for setTimeout callback
|
||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
||||
await fakeTimeouts.advanceBy(600)
|
||||
|
||||
// #then: Truncation was attempted
|
||||
// then: Truncation was attempted
|
||||
expect(truncateSpy).toHaveBeenCalled()
|
||||
|
||||
// #then: Summarize should NOT be called (early return from sufficient truncation)
|
||||
// then: Summarize should NOT be called (early return from sufficient truncation)
|
||||
expect(mockClient.session.summarize).not.toHaveBeenCalled()
|
||||
|
||||
// #then: prompt_async should be called (Continue after successful truncation)
|
||||
expect(mockClient.session.prompt_async).toHaveBeenCalled()
|
||||
// then: promptAsync should be called (Continue after successful truncation)
|
||||
expect(mockClient.session.promptAsync).toHaveBeenCalled()
|
||||
|
||||
// #then: Lock should be cleared
|
||||
// then: Lock should be cleared
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||
|
||||
truncateSpy.mockRestore()
|
||||
|
||||
@@ -1,271 +1,30 @@
|
||||
import type {
|
||||
AutoCompactState,
|
||||
RetryState,
|
||||
TruncateState,
|
||||
} from "./types";
|
||||
import type { AutoCompactState } from "./types";
|
||||
import type { OhMyOpenCodeConfig } from "../../config";
|
||||
import type { ExperimentalConfig } from "../../config";
|
||||
import { RETRY_CONFIG, TRUNCATE_CONFIG } from "./types";
|
||||
import { TRUNCATE_CONFIG } from "./types";
|
||||
|
||||
import type { Client } from "./client";
|
||||
import { getOrCreateTruncateState } from "./state";
|
||||
import {
|
||||
findLargestToolResult,
|
||||
truncateToolResult,
|
||||
truncateUntilTargetTokens,
|
||||
} from "./storage";
|
||||
import {
|
||||
findEmptyMessages,
|
||||
findEmptyMessageByIndex,
|
||||
injectTextPart,
|
||||
replaceEmptyTextParts,
|
||||
} from "../session-recovery/storage";
|
||||
import { log } from "../../shared/logger";
|
||||
runAggressiveTruncationStrategy,
|
||||
runSummarizeRetryStrategy,
|
||||
} from "./recovery-strategy";
|
||||
|
||||
const PLACEHOLDER_TEXT = "[user interrupted]";
|
||||
|
||||
type Client = {
|
||||
session: {
|
||||
messages: (opts: {
|
||||
path: { id: string };
|
||||
query?: { directory?: string };
|
||||
}) => Promise<unknown>;
|
||||
summarize: (opts: {
|
||||
path: { id: string };
|
||||
body: { providerID: string; modelID: string };
|
||||
query: { directory: string };
|
||||
}) => Promise<unknown>;
|
||||
revert: (opts: {
|
||||
path: { id: string };
|
||||
body: { messageID: string; partID?: string };
|
||||
query: { directory: string };
|
||||
}) => Promise<unknown>;
|
||||
prompt_async: (opts: {
|
||||
path: { id: string };
|
||||
body: { parts: Array<{ type: string; text: string }> };
|
||||
query: { directory: string };
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
tui: {
|
||||
showToast: (opts: {
|
||||
body: {
|
||||
title: string;
|
||||
message: string;
|
||||
variant: string;
|
||||
duration: number;
|
||||
};
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
function getOrCreateRetryState(
|
||||
autoCompactState: AutoCompactState,
|
||||
sessionID: string,
|
||||
): RetryState {
|
||||
let state = autoCompactState.retryStateBySession.get(sessionID);
|
||||
if (!state) {
|
||||
state = { attempt: 0, lastAttemptTime: 0 };
|
||||
autoCompactState.retryStateBySession.set(sessionID, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function getOrCreateTruncateState(
|
||||
autoCompactState: AutoCompactState,
|
||||
sessionID: string,
|
||||
): TruncateState {
|
||||
let state = autoCompactState.truncateStateBySession.get(sessionID);
|
||||
if (!state) {
|
||||
state = { truncateAttempt: 0 };
|
||||
autoCompactState.truncateStateBySession.set(sessionID, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function sanitizeEmptyMessagesBeforeSummarize(sessionID: string): number {
|
||||
const emptyMessageIds = findEmptyMessages(sessionID);
|
||||
if (emptyMessageIds.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let fixedCount = 0;
|
||||
for (const messageID of emptyMessageIds) {
|
||||
const replaced = replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT);
|
||||
if (replaced) {
|
||||
fixedCount++;
|
||||
} else {
|
||||
const injected = injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT);
|
||||
if (injected) {
|
||||
fixedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fixedCount > 0) {
|
||||
log("[auto-compact] pre-summarize sanitization fixed empty messages", {
|
||||
sessionID,
|
||||
fixedCount,
|
||||
totalEmpty: emptyMessageIds.length,
|
||||
});
|
||||
}
|
||||
|
||||
return fixedCount;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
|
||||
export async function getLastAssistant(
|
||||
sessionID: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
client: any,
|
||||
directory: string,
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
const resp = await (client as Client).session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory },
|
||||
});
|
||||
|
||||
const data = (resp as { data?: unknown[] }).data;
|
||||
if (!Array.isArray(data)) return null;
|
||||
|
||||
const reversed = [...data].reverse();
|
||||
const last = reversed.find((m) => {
|
||||
const msg = m as Record<string, unknown>;
|
||||
const info = msg.info as Record<string, unknown> | undefined;
|
||||
return info?.role === "assistant";
|
||||
});
|
||||
if (!last) return null;
|
||||
return (last as { info?: Record<string, unknown> }).info ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
function clearSessionState(
|
||||
autoCompactState: AutoCompactState,
|
||||
sessionID: string,
|
||||
): void {
|
||||
autoCompactState.pendingCompact.delete(sessionID);
|
||||
autoCompactState.errorDataBySession.delete(sessionID);
|
||||
autoCompactState.retryStateBySession.delete(sessionID);
|
||||
autoCompactState.truncateStateBySession.delete(sessionID);
|
||||
autoCompactState.emptyContentAttemptBySession.delete(sessionID);
|
||||
autoCompactState.compactionInProgress.delete(sessionID);
|
||||
}
|
||||
|
||||
function getOrCreateEmptyContentAttempt(
|
||||
autoCompactState: AutoCompactState,
|
||||
sessionID: string,
|
||||
): number {
|
||||
return autoCompactState.emptyContentAttemptBySession.get(sessionID) ?? 0;
|
||||
}
|
||||
|
||||
async function fixEmptyMessages(
|
||||
sessionID: string,
|
||||
autoCompactState: AutoCompactState,
|
||||
client: Client,
|
||||
messageIndex?: number,
|
||||
): Promise<boolean> {
|
||||
const attempt = getOrCreateEmptyContentAttempt(autoCompactState, sessionID);
|
||||
autoCompactState.emptyContentAttemptBySession.set(sessionID, attempt + 1);
|
||||
|
||||
let fixed = false;
|
||||
const fixedMessageIds: string[] = [];
|
||||
|
||||
if (messageIndex !== undefined) {
|
||||
const targetMessageId = findEmptyMessageByIndex(sessionID, messageIndex);
|
||||
if (targetMessageId) {
|
||||
const replaced = replaceEmptyTextParts(
|
||||
targetMessageId,
|
||||
"[user interrupted]",
|
||||
);
|
||||
if (replaced) {
|
||||
fixed = true;
|
||||
fixedMessageIds.push(targetMessageId);
|
||||
} else {
|
||||
const injected = injectTextPart(
|
||||
sessionID,
|
||||
targetMessageId,
|
||||
"[user interrupted]",
|
||||
);
|
||||
if (injected) {
|
||||
fixed = true;
|
||||
fixedMessageIds.push(targetMessageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!fixed) {
|
||||
const emptyMessageIds = findEmptyMessages(sessionID);
|
||||
if (emptyMessageIds.length === 0) {
|
||||
await client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Empty Content Error",
|
||||
message: "No empty messages found in storage. Cannot auto-recover.",
|
||||
variant: "error",
|
||||
duration: 5000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const messageID of emptyMessageIds) {
|
||||
const replaced = replaceEmptyTextParts(messageID, "[user interrupted]");
|
||||
if (replaced) {
|
||||
fixed = true;
|
||||
fixedMessageIds.push(messageID);
|
||||
} else {
|
||||
const injected = injectTextPart(
|
||||
sessionID,
|
||||
messageID,
|
||||
"[user interrupted]",
|
||||
);
|
||||
if (injected) {
|
||||
fixed = true;
|
||||
fixedMessageIds.push(messageID);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fixed) {
|
||||
await client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Session Recovery",
|
||||
message: `Fixed ${fixedMessageIds.length} empty message(s). Retrying...`,
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
return fixed;
|
||||
}
|
||||
export { getLastAssistant } from "./message-builder";
|
||||
|
||||
export async function executeCompact(
|
||||
sessionID: string,
|
||||
msg: Record<string, unknown>,
|
||||
autoCompactState: AutoCompactState,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
client: any,
|
||||
client: Client,
|
||||
directory: string,
|
||||
experimental?: ExperimentalConfig,
|
||||
pluginConfig: OhMyOpenCodeConfig,
|
||||
_experimental?: ExperimentalConfig
|
||||
): Promise<void> {
|
||||
void _experimental
|
||||
|
||||
if (autoCompactState.compactionInProgress.has(sessionID)) {
|
||||
await (client as Client).tui
|
||||
await client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Compact In Progress",
|
||||
@@ -294,191 +53,30 @@ export async function executeCompact(
|
||||
isOverLimit &&
|
||||
truncateState.truncateAttempt < TRUNCATE_CONFIG.maxTruncateAttempts
|
||||
) {
|
||||
log("[auto-compact] PHASE 2: aggressive truncation triggered", {
|
||||
const result = await runAggressiveTruncationStrategy({
|
||||
sessionID,
|
||||
autoCompactState,
|
||||
client: client,
|
||||
directory,
|
||||
truncateAttempt: truncateState.truncateAttempt,
|
||||
currentTokens: errorData.currentTokens,
|
||||
maxTokens: errorData.maxTokens,
|
||||
targetRatio: TRUNCATE_CONFIG.targetTokenRatio,
|
||||
});
|
||||
|
||||
const aggressiveResult = truncateUntilTargetTokens(
|
||||
sessionID,
|
||||
errorData.currentTokens,
|
||||
errorData.maxTokens,
|
||||
TRUNCATE_CONFIG.targetTokenRatio,
|
||||
TRUNCATE_CONFIG.charsPerToken,
|
||||
);
|
||||
|
||||
if (aggressiveResult.truncatedCount > 0) {
|
||||
truncateState.truncateAttempt += aggressiveResult.truncatedCount;
|
||||
|
||||
const toolNames = aggressiveResult.truncatedTools
|
||||
.map((t) => t.toolName)
|
||||
.join(", ");
|
||||
const statusMsg = aggressiveResult.sufficient
|
||||
? `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)})`
|
||||
: `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)}) - continuing to summarize...`;
|
||||
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: aggressiveResult.sufficient
|
||||
? "Truncation Complete"
|
||||
: "Partial Truncation",
|
||||
message: `${statusMsg}: ${toolNames}`,
|
||||
variant: aggressiveResult.sufficient ? "success" : "warning",
|
||||
duration: 4000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
log("[auto-compact] aggressive truncation completed", aggressiveResult);
|
||||
|
||||
// Only return early if truncation was sufficient to get under token limit
|
||||
// Otherwise fall through to PHASE 3 (Summarize)
|
||||
if (aggressiveResult.sufficient) {
|
||||
clearSessionState(autoCompactState, sessionID);
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await (client as Client).session.prompt_async({
|
||||
path: { id: sessionID },
|
||||
body: { auto: true } as never,
|
||||
query: { directory },
|
||||
});
|
||||
} catch {}
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
// Truncation was insufficient - fall through to Summarize
|
||||
log("[auto-compact] truncation insufficient, falling through to summarize", {
|
||||
sessionID,
|
||||
truncatedCount: aggressiveResult.truncatedCount,
|
||||
sufficient: aggressiveResult.sufficient,
|
||||
});
|
||||
}
|
||||
truncateState.truncateAttempt = result.nextTruncateAttempt;
|
||||
if (result.handled) return;
|
||||
}
|
||||
|
||||
// PHASE 3: Summarize - fallback when truncation insufficient or no tool outputs
|
||||
const retryState = getOrCreateRetryState(autoCompactState, sessionID);
|
||||
|
||||
if (errorData?.errorType?.includes("non-empty content")) {
|
||||
const attempt = getOrCreateEmptyContentAttempt(
|
||||
autoCompactState,
|
||||
sessionID,
|
||||
);
|
||||
if (attempt < 3) {
|
||||
const fixed = await fixEmptyMessages(
|
||||
sessionID,
|
||||
autoCompactState,
|
||||
client as Client,
|
||||
errorData.messageIndex,
|
||||
);
|
||||
if (fixed) {
|
||||
setTimeout(() => {
|
||||
executeCompact(
|
||||
sessionID,
|
||||
msg,
|
||||
autoCompactState,
|
||||
client,
|
||||
directory,
|
||||
experimental,
|
||||
);
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Recovery Failed",
|
||||
message:
|
||||
"Max recovery attempts (3) reached for empty content error. Please start a new session.",
|
||||
variant: "error",
|
||||
duration: 10000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (Date.now() - retryState.lastAttemptTime > 300000) {
|
||||
retryState.attempt = 0;
|
||||
autoCompactState.truncateStateBySession.delete(sessionID);
|
||||
}
|
||||
|
||||
if (retryState.attempt < RETRY_CONFIG.maxAttempts) {
|
||||
retryState.attempt++;
|
||||
retryState.lastAttemptTime = Date.now();
|
||||
|
||||
const providerID = msg.providerID as string | undefined;
|
||||
const modelID = msg.modelID as string | undefined;
|
||||
|
||||
if (providerID && modelID) {
|
||||
try {
|
||||
sanitizeEmptyMessagesBeforeSummarize(sessionID);
|
||||
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Auto Compact",
|
||||
message: `Summarizing session (attempt ${retryState.attempt}/${RETRY_CONFIG.maxAttempts})...`,
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
const summarizeBody = { providerID, modelID, auto: true }
|
||||
await (client as Client).session.summarize({
|
||||
path: { id: sessionID },
|
||||
body: summarizeBody as never,
|
||||
query: { directory },
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
const delay =
|
||||
RETRY_CONFIG.initialDelayMs *
|
||||
Math.pow(RETRY_CONFIG.backoffFactor, retryState.attempt - 1);
|
||||
const cappedDelay = Math.min(delay, RETRY_CONFIG.maxDelayMs);
|
||||
|
||||
setTimeout(() => {
|
||||
executeCompact(
|
||||
sessionID,
|
||||
msg,
|
||||
autoCompactState,
|
||||
client,
|
||||
directory,
|
||||
experimental,
|
||||
);
|
||||
}, cappedDelay);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Summarize Skipped",
|
||||
message: "Missing providerID or modelID.",
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
clearSessionState(autoCompactState, sessionID);
|
||||
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Auto Compact Failed",
|
||||
message: "All recovery attempts failed. Please start a new session.",
|
||||
variant: "error",
|
||||
duration: 5000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
await runSummarizeRetryStrategy({
|
||||
sessionID,
|
||||
msg,
|
||||
autoCompactState,
|
||||
client: client,
|
||||
directory,
|
||||
pluginConfig,
|
||||
errorType: errorData?.errorType,
|
||||
messageIndex: errorData?.messageIndex,
|
||||
})
|
||||
} finally {
|
||||
autoCompactState.compactionInProgress.delete(sessionID);
|
||||
}
|
||||
|
||||
@@ -1,151 +1,8 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { AutoCompactState, ParsedTokenLimitError } from "./types"
|
||||
import type { ExperimentalConfig } from "../../config"
|
||||
import { parseAnthropicTokenLimitError } from "./parser"
|
||||
import { executeCompact, getLastAssistant } from "./executor"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
export interface AnthropicContextWindowLimitRecoveryOptions {
|
||||
experimental?: ExperimentalConfig
|
||||
}
|
||||
|
||||
function createRecoveryState(): AutoCompactState {
|
||||
return {
|
||||
pendingCompact: new Set<string>(),
|
||||
errorDataBySession: new Map<string, ParsedTokenLimitError>(),
|
||||
retryStateBySession: new Map(),
|
||||
truncateStateBySession: new Map(),
|
||||
emptyContentAttemptBySession: new Map(),
|
||||
compactionInProgress: new Set<string>(),
|
||||
}
|
||||
}
|
||||
|
||||
export function createAnthropicContextWindowLimitRecoveryHook(ctx: PluginInput, options?: AnthropicContextWindowLimitRecoveryOptions) {
|
||||
const autoCompactState = createRecoveryState()
|
||||
const experimental = options?.experimental
|
||||
|
||||
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
autoCompactState.pendingCompact.delete(sessionInfo.id)
|
||||
autoCompactState.errorDataBySession.delete(sessionInfo.id)
|
||||
autoCompactState.retryStateBySession.delete(sessionInfo.id)
|
||||
autoCompactState.truncateStateBySession.delete(sessionInfo.id)
|
||||
autoCompactState.emptyContentAttemptBySession.delete(sessionInfo.id)
|
||||
autoCompactState.compactionInProgress.delete(sessionInfo.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
log("[auto-compact] session.error received", { sessionID, error: props?.error })
|
||||
if (!sessionID) return
|
||||
|
||||
const parsed = parseAnthropicTokenLimitError(props?.error)
|
||||
log("[auto-compact] parsed result", { parsed, hasError: !!props?.error })
|
||||
if (parsed) {
|
||||
autoCompactState.pendingCompact.add(sessionID)
|
||||
autoCompactState.errorDataBySession.set(sessionID, parsed)
|
||||
|
||||
if (autoCompactState.compactionInProgress.has(sessionID)) {
|
||||
return
|
||||
}
|
||||
|
||||
const lastAssistant = await getLastAssistant(sessionID, ctx.client, ctx.directory)
|
||||
const providerID = parsed.providerID ?? (lastAssistant?.providerID as string | undefined)
|
||||
const modelID = parsed.modelID ?? (lastAssistant?.modelID as string | undefined)
|
||||
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Context Limit Hit",
|
||||
message: "Truncating large tool outputs and recovering...",
|
||||
variant: "warning" as const,
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
setTimeout(() => {
|
||||
executeCompact(
|
||||
sessionID,
|
||||
{ providerID, modelID },
|
||||
autoCompactState,
|
||||
ctx.client,
|
||||
ctx.directory,
|
||||
experimental
|
||||
)
|
||||
}, 300)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "message.updated") {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = info?.sessionID as string | undefined
|
||||
|
||||
if (sessionID && info?.role === "assistant" && info.error) {
|
||||
log("[auto-compact] message.updated with error", { sessionID, error: info.error })
|
||||
const parsed = parseAnthropicTokenLimitError(info.error)
|
||||
log("[auto-compact] message.updated parsed result", { parsed })
|
||||
if (parsed) {
|
||||
parsed.providerID = info.providerID as string | undefined
|
||||
parsed.modelID = info.modelID as string | undefined
|
||||
autoCompactState.pendingCompact.add(sessionID)
|
||||
autoCompactState.errorDataBySession.set(sessionID, parsed)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
if (!autoCompactState.pendingCompact.has(sessionID)) return
|
||||
|
||||
const errorData = autoCompactState.errorDataBySession.get(sessionID)
|
||||
const lastAssistant = await getLastAssistant(sessionID, ctx.client, ctx.directory)
|
||||
|
||||
if (lastAssistant?.summary === true) {
|
||||
autoCompactState.pendingCompact.delete(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
const providerID = errorData?.providerID ?? (lastAssistant?.providerID as string | undefined)
|
||||
const modelID = errorData?.modelID ?? (lastAssistant?.modelID as string | undefined)
|
||||
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Auto Compact",
|
||||
message: "Token limit exceeded. Attempting recovery...",
|
||||
variant: "warning" as const,
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
await executeCompact(
|
||||
sessionID,
|
||||
{ providerID, modelID },
|
||||
autoCompactState,
|
||||
ctx.client,
|
||||
ctx.directory,
|
||||
experimental
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
event: eventHandler,
|
||||
}
|
||||
}
|
||||
|
||||
export { createAnthropicContextWindowLimitRecoveryHook } from "./recovery-hook"
|
||||
export type { AnthropicContextWindowLimitRecoveryOptions } from "./recovery-hook"
|
||||
export type { AutoCompactState, ParsedTokenLimitError, TruncateState } from "./types"
|
||||
export { parseAnthropicTokenLimitError } from "./parser"
|
||||
export { executeCompact, getLastAssistant } from "./executor"
|
||||
export * from "./state"
|
||||
export * from "./message-builder"
|
||||
export * from "./recovery-strategy"
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { log } from "../../shared/logger"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { normalizeSDKResponse } from "../../shared"
|
||||
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
|
||||
import {
|
||||
findEmptyMessages,
|
||||
injectTextPart,
|
||||
replaceEmptyTextParts,
|
||||
} from "../session-recovery/storage"
|
||||
import { replaceEmptyTextPartsAsync } from "../session-recovery/storage/empty-text"
|
||||
import { injectTextPartAsync } from "../session-recovery/storage/text-part-injector"
|
||||
import type { Client } from "./client"
|
||||
|
||||
export const PLACEHOLDER_TEXT = "[user interrupted]"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
interface SDKPart {
|
||||
type?: string
|
||||
text?: string
|
||||
}
|
||||
|
||||
interface SDKMessage {
|
||||
info?: { id?: string }
|
||||
parts?: SDKPart[]
|
||||
}
|
||||
|
||||
const IGNORE_TYPES = new Set(["thinking", "redacted_thinking", "meta"])
|
||||
const TOOL_TYPES = new Set(["tool", "tool_use", "tool_result"])
|
||||
|
||||
function messageHasContentFromSDK(message: SDKMessage): boolean {
|
||||
const parts = message.parts
|
||||
if (!parts || parts.length === 0) return false
|
||||
|
||||
for (const part of parts) {
|
||||
const type = part.type
|
||||
if (!type) continue
|
||||
if (IGNORE_TYPES.has(type)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (type === "text") {
|
||||
if (part.text?.trim()) return true
|
||||
continue
|
||||
}
|
||||
|
||||
if (TOOL_TYPES.has(type)) return true
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Messages with only thinking/meta parts are treated as empty
|
||||
// to align with file-based logic (messageHasContent)
|
||||
return false
|
||||
}
|
||||
|
||||
async function findEmptyMessageIdsFromSDK(
|
||||
client: OpencodeClient,
|
||||
sessionID: string,
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const response = (await client.session.messages({
|
||||
path: { id: sessionID },
|
||||
})) as { data?: SDKMessage[] }
|
||||
const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true })
|
||||
|
||||
const emptyIds: string[] = []
|
||||
for (const message of messages) {
|
||||
const messageID = message.info?.id
|
||||
if (!messageID) continue
|
||||
if (!messageHasContentFromSDK(message)) {
|
||||
emptyIds.push(messageID)
|
||||
}
|
||||
}
|
||||
|
||||
return emptyIds
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function sanitizeEmptyMessagesBeforeSummarize(
|
||||
sessionID: string,
|
||||
client?: OpencodeClient,
|
||||
): Promise<number> {
|
||||
if (client && isSqliteBackend()) {
|
||||
const emptyMessageIds = await findEmptyMessageIdsFromSDK(client, sessionID)
|
||||
if (emptyMessageIds.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
let fixedCount = 0
|
||||
for (const messageID of emptyMessageIds) {
|
||||
const replaced = await replaceEmptyTextPartsAsync(client, sessionID, messageID, PLACEHOLDER_TEXT)
|
||||
if (replaced) {
|
||||
fixedCount++
|
||||
} else {
|
||||
const injected = await injectTextPartAsync(client, sessionID, messageID, PLACEHOLDER_TEXT)
|
||||
if (injected) {
|
||||
fixedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fixedCount > 0) {
|
||||
log("[auto-compact] pre-summarize sanitization fixed empty messages", {
|
||||
sessionID,
|
||||
fixedCount,
|
||||
totalEmpty: emptyMessageIds.length,
|
||||
})
|
||||
}
|
||||
|
||||
return fixedCount
|
||||
}
|
||||
|
||||
const emptyMessageIds = findEmptyMessages(sessionID)
|
||||
if (emptyMessageIds.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
let fixedCount = 0
|
||||
for (const messageID of emptyMessageIds) {
|
||||
const replaced = replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT)
|
||||
if (replaced) {
|
||||
fixedCount++
|
||||
} else {
|
||||
const injected = injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT)
|
||||
if (injected) {
|
||||
fixedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fixedCount > 0) {
|
||||
log("[auto-compact] pre-summarize sanitization fixed empty messages", {
|
||||
sessionID,
|
||||
fixedCount,
|
||||
totalEmpty: emptyMessageIds.length,
|
||||
})
|
||||
}
|
||||
|
||||
return fixedCount
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes}B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
|
||||
}
|
||||
|
||||
export async function getLastAssistant(
|
||||
sessionID: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
client: any,
|
||||
directory: string,
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
const resp = await (client as Client).session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory },
|
||||
})
|
||||
|
||||
const data = (resp as { data?: unknown[] }).data
|
||||
if (!Array.isArray(data)) return null
|
||||
|
||||
const reversed = [...data].reverse()
|
||||
const last = reversed.find((m) => {
|
||||
const msg = m as Record<string, unknown>
|
||||
const info = msg.info as Record<string, unknown> | undefined
|
||||
return info?.role === "assistant"
|
||||
})
|
||||
if (!last) return null
|
||||
return (last as { info?: Record<string, unknown> }).info ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { existsSync, readdirSync } from "node:fs"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { getMessageDir } from "../../shared/opencode-message-dir"
|
||||
import { normalizeSDKResponse } from "../../shared"
|
||||
|
||||
export { getMessageDir }
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
interface SDKMessage {
|
||||
info: { id: string }
|
||||
parts: unknown[]
|
||||
}
|
||||
|
||||
export async function getMessageIdsFromSDK(
|
||||
client: OpencodeClient,
|
||||
sessionID: string
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const response = await client.session.messages({ path: { id: sessionID } })
|
||||
const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true })
|
||||
return messages.map(msg => msg.info.id)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function getMessageIds(sessionID: string): string[] {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
if (!messageDir || !existsSync(messageDir)) return []
|
||||
|
||||
const messageIds: string[] = []
|
||||
for (const file of readdirSync(messageDir)) {
|
||||
if (!file.endsWith(".json")) continue
|
||||
const messageId = file.replace(".json", "")
|
||||
messageIds.push(messageId)
|
||||
}
|
||||
|
||||
return messageIds
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { parseAnthropicTokenLimitError } from "./parser"
|
||||
|
||||
describe("parseAnthropicTokenLimitError", () => {
|
||||
it("#given a standard token limit error string #when parsing #then extracts tokens", () => {
|
||||
//#given
|
||||
const error = "prompt is too long: 250000 tokens > 200000 maximum"
|
||||
|
||||
//#when
|
||||
const result = parseAnthropicTokenLimitError(error)
|
||||
|
||||
//#then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.currentTokens).toBe(250000)
|
||||
expect(result!.maxTokens).toBe(200000)
|
||||
})
|
||||
|
||||
it("#given a non-token-limit error #when parsing #then returns null", () => {
|
||||
//#given
|
||||
const error = { message: "internal server error" }
|
||||
|
||||
//#when
|
||||
const result = parseAnthropicTokenLimitError(error)
|
||||
|
||||
//#then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("#given null input #when parsing #then returns null", () => {
|
||||
//#given
|
||||
const error = null
|
||||
|
||||
//#when
|
||||
const result = parseAnthropicTokenLimitError(error)
|
||||
|
||||
//#then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("#given a proxy error with non-standard structure #when parsing #then returns null without crashing", () => {
|
||||
//#given
|
||||
const proxyError = {
|
||||
data: [1, 2, 3],
|
||||
error: "string-not-object",
|
||||
message: "Failed to process error response",
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = parseAnthropicTokenLimitError(proxyError)
|
||||
|
||||
//#then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("#given a circular reference error #when parsing #then returns null without crashing", () => {
|
||||
//#given
|
||||
const circular: Record<string, unknown> = { message: "prompt is too long" }
|
||||
circular.self = circular
|
||||
|
||||
//#when
|
||||
const result = parseAnthropicTokenLimitError(circular)
|
||||
|
||||
//#then
|
||||
expect(result).not.toBeNull()
|
||||
})
|
||||
|
||||
it("#given an error where data.responseBody has invalid JSON #when parsing #then handles gracefully", () => {
|
||||
//#given
|
||||
const error = {
|
||||
data: { responseBody: "not valid json {{{" },
|
||||
message: "prompt is too long with 300000 tokens exceeds 200000",
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = parseAnthropicTokenLimitError(error)
|
||||
|
||||
//#then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.currentTokens).toBe(300000)
|
||||
expect(result!.maxTokens).toBe(200000)
|
||||
})
|
||||
|
||||
it("#given an error with data as a string (not object) #when parsing #then does not crash", () => {
|
||||
//#given
|
||||
const error = {
|
||||
data: "some-string-data",
|
||||
message: "token limit exceeded",
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = parseAnthropicTokenLimitError(error)
|
||||
|
||||
//#then
|
||||
expect(result).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -70,10 +70,18 @@ function isTokenLimitError(text: string): boolean {
|
||||
return false
|
||||
}
|
||||
const lower = text.toLowerCase()
|
||||
return TOKEN_LIMIT_KEYWORDS.some((kw) => lower.includes(kw.toLowerCase()))
|
||||
return TOKEN_LIMIT_KEYWORDS.some((kw) => lower.includes(kw))
|
||||
}
|
||||
|
||||
export function parseAnthropicTokenLimitError(err: unknown): ParsedTokenLimitError | null {
|
||||
try {
|
||||
return parseAnthropicTokenLimitErrorUnsafe(err)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function parseAnthropicTokenLimitErrorUnsafe(err: unknown): ParsedTokenLimitError | null {
|
||||
if (typeof err === "string") {
|
||||
if (err.toLowerCase().includes("non-empty content")) {
|
||||
return {
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
||||
import { readdirSync, readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { PruningState, ToolCallSignature } from "./pruning-types"
|
||||
import { estimateTokens } from "./pruning-types"
|
||||
import { log } from "../../shared/logger"
|
||||
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
import { getMessageDir } from "../../shared/opencode-message-dir"
|
||||
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
|
||||
import { normalizeSDKResponse } from "../../shared"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
export interface DeduplicationConfig {
|
||||
enabled: boolean
|
||||
@@ -43,20 +48,6 @@ function sortObject(obj: unknown): unknown {
|
||||
return sorted
|
||||
}
|
||||
|
||||
function getMessageDir(sessionID: string): string | null {
|
||||
if (!existsSync(MESSAGE_STORAGE)) return null
|
||||
|
||||
const directPath = join(MESSAGE_STORAGE, sessionID)
|
||||
if (existsSync(directPath)) return directPath
|
||||
|
||||
for (const dir of readdirSync(MESSAGE_STORAGE)) {
|
||||
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
|
||||
if (existsSync(sessionPath)) return sessionPath
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function readMessages(sessionID: string): MessagePart[] {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
if (!messageDir) return []
|
||||
@@ -64,7 +55,7 @@ function readMessages(sessionID: string): MessagePart[] {
|
||||
const messages: MessagePart[] = []
|
||||
|
||||
try {
|
||||
const files = readdirSync(messageDir).filter(f => f.endsWith(".json"))
|
||||
const files = readdirSync(messageDir).filter((f: string) => f.endsWith(".json"))
|
||||
for (const file of files) {
|
||||
const content = readFileSync(join(messageDir, file), "utf-8")
|
||||
const data = JSON.parse(content)
|
||||
@@ -79,15 +70,29 @@ function readMessages(sessionID: string): MessagePart[] {
|
||||
return messages
|
||||
}
|
||||
|
||||
export function executeDeduplication(
|
||||
async function readMessagesFromSDK(client: OpencodeClient, sessionID: string): Promise<MessagePart[]> {
|
||||
try {
|
||||
const response = await client.session.messages({ path: { id: sessionID } })
|
||||
const rawMessages = normalizeSDKResponse(response, [] as Array<{ parts?: ToolPart[] }>, { preferResponseOnMissingData: true })
|
||||
return rawMessages.filter((m) => m.parts) as MessagePart[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeDeduplication(
|
||||
sessionID: string,
|
||||
state: PruningState,
|
||||
config: DeduplicationConfig,
|
||||
protectedTools: Set<string>
|
||||
): number {
|
||||
protectedTools: Set<string>,
|
||||
client?: OpencodeClient,
|
||||
): Promise<number> {
|
||||
if (!config.enabled) return 0
|
||||
|
||||
const messages = readMessages(sessionID)
|
||||
const messages = (client && isSqliteBackend())
|
||||
? await readMessagesFromSDK(client, sessionID)
|
||||
: readMessages(sessionID)
|
||||
|
||||
const signatures = new Map<string, ToolCallSignature[]>()
|
||||
|
||||
let currentTurn = 0
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { getOpenCodeStorageDir } from "../../shared/data-path"
|
||||
import { truncateToolResult } from "./storage"
|
||||
import { truncateToolResultAsync } from "./tool-result-storage-sdk"
|
||||
import { log } from "../../shared/logger"
|
||||
import { getMessageDir } from "../../shared/opencode-message-dir"
|
||||
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
|
||||
import { normalizeSDKResponse } from "../../shared"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
interface StoredToolPart {
|
||||
type?: string
|
||||
callID?: string
|
||||
truncated?: boolean
|
||||
state?: {
|
||||
output?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface SDKToolPart {
|
||||
id: string
|
||||
type: string
|
||||
callID?: string
|
||||
tool?: string
|
||||
state?: { output?: string; time?: { compacted?: number } }
|
||||
}
|
||||
|
||||
interface SDKMessage {
|
||||
info?: { id?: string }
|
||||
parts?: SDKToolPart[]
|
||||
}
|
||||
|
||||
function getPartStorage(): string {
|
||||
return join(getOpenCodeStorageDir(), "part")
|
||||
}
|
||||
|
||||
function getMessageIds(sessionID: string): string[] {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
if (!messageDir) return []
|
||||
|
||||
const messageIds: string[] = []
|
||||
for (const file of readdirSync(messageDir)) {
|
||||
if (!file.endsWith(".json")) continue
|
||||
messageIds.push(file.replace(".json", ""))
|
||||
}
|
||||
|
||||
return messageIds
|
||||
}
|
||||
|
||||
export async function truncateToolOutputsByCallId(
|
||||
sessionID: string,
|
||||
callIds: Set<string>,
|
||||
client?: OpencodeClient,
|
||||
): Promise<{ truncatedCount: number }> {
|
||||
if (callIds.size === 0) return { truncatedCount: 0 }
|
||||
|
||||
if (client && isSqliteBackend()) {
|
||||
return truncateToolOutputsByCallIdFromSDK(client, sessionID, callIds)
|
||||
}
|
||||
|
||||
const messageIds = getMessageIds(sessionID)
|
||||
if (messageIds.length === 0) return { truncatedCount: 0 }
|
||||
|
||||
let truncatedCount = 0
|
||||
|
||||
for (const messageID of messageIds) {
|
||||
const partDir = join(getPartStorage(), messageID)
|
||||
if (!existsSync(partDir)) continue
|
||||
|
||||
for (const file of readdirSync(partDir)) {
|
||||
if (!file.endsWith(".json")) continue
|
||||
const partPath = join(partDir, file)
|
||||
|
||||
try {
|
||||
const content = readFileSync(partPath, "utf-8")
|
||||
const part = JSON.parse(content) as StoredToolPart
|
||||
|
||||
if (part.type !== "tool" || !part.callID) continue
|
||||
if (!callIds.has(part.callID)) continue
|
||||
if (!part.state?.output || part.truncated) continue
|
||||
|
||||
const result = truncateToolResult(partPath)
|
||||
if (result.success) {
|
||||
truncatedCount++
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (truncatedCount > 0) {
|
||||
log("[auto-compact] pruned duplicate tool outputs", {
|
||||
sessionID,
|
||||
truncatedCount,
|
||||
})
|
||||
}
|
||||
|
||||
return { truncatedCount }
|
||||
}
|
||||
|
||||
async function truncateToolOutputsByCallIdFromSDK(
|
||||
client: OpencodeClient,
|
||||
sessionID: string,
|
||||
callIds: Set<string>,
|
||||
): Promise<{ truncatedCount: number }> {
|
||||
try {
|
||||
const response = await client.session.messages({ path: { id: sessionID } })
|
||||
const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true })
|
||||
let truncatedCount = 0
|
||||
|
||||
for (const msg of messages) {
|
||||
const messageID = msg.info?.id
|
||||
if (!messageID || !msg.parts) continue
|
||||
|
||||
for (const part of msg.parts) {
|
||||
if (part.type !== "tool" || !part.callID) continue
|
||||
if (!callIds.has(part.callID)) continue
|
||||
if (!part.state?.output || part.state?.time?.compacted) continue
|
||||
|
||||
const result = await truncateToolResultAsync(client, sessionID, messageID, part.id, part)
|
||||
if (result.success) {
|
||||
truncatedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (truncatedCount > 0) {
|
||||
log("[auto-compact] pruned duplicate tool outputs (SDK)", {
|
||||
sessionID,
|
||||
truncatedCount,
|
||||
})
|
||||
}
|
||||
|
||||
return { truncatedCount }
|
||||
} catch {
|
||||
return { truncatedCount: 0 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, test, expect, mock, beforeEach, afterAll } from "bun:test"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { ExperimentalConfig } from "../../config"
|
||||
import * as originalDeduplicationRecovery from "./deduplication-recovery"
|
||||
|
||||
const attemptDeduplicationRecoveryMock = mock(async () => {})
|
||||
|
||||
mock.module("./deduplication-recovery", () => ({
|
||||
attemptDeduplicationRecovery: attemptDeduplicationRecoveryMock,
|
||||
}))
|
||||
|
||||
afterAll(() => {
|
||||
mock.module("./deduplication-recovery", () => originalDeduplicationRecovery)
|
||||
})
|
||||
|
||||
function createImmediateTimeouts(): () => void {
|
||||
const originalSetTimeout = globalThis.setTimeout
|
||||
const originalClearTimeout = globalThis.clearTimeout
|
||||
|
||||
globalThis.setTimeout = ((callback: (...args: unknown[]) => void, _delay?: number, ...args: unknown[]) => {
|
||||
callback(...args)
|
||||
return 0 as unknown as ReturnType<typeof setTimeout>
|
||||
}) as typeof setTimeout
|
||||
|
||||
globalThis.clearTimeout = ((_: ReturnType<typeof setTimeout>) => {}) as typeof clearTimeout
|
||||
|
||||
return () => {
|
||||
globalThis.setTimeout = originalSetTimeout
|
||||
globalThis.clearTimeout = originalClearTimeout
|
||||
}
|
||||
}
|
||||
|
||||
describe("createAnthropicContextWindowLimitRecoveryHook", () => {
|
||||
beforeEach(() => {
|
||||
attemptDeduplicationRecoveryMock.mockClear()
|
||||
})
|
||||
|
||||
test("calls deduplication recovery when compaction is already in progress", async () => {
|
||||
//#given
|
||||
const restoreTimeouts = createImmediateTimeouts()
|
||||
|
||||
const experimental = {
|
||||
dynamic_context_pruning: {
|
||||
enabled: true,
|
||||
strategies: {
|
||||
deduplication: { enabled: true },
|
||||
},
|
||||
},
|
||||
} satisfies ExperimentalConfig
|
||||
|
||||
let resolveSummarize: (() => void) | null = null
|
||||
const summarizePromise = new Promise<void>((resolve) => {
|
||||
resolveSummarize = resolve
|
||||
})
|
||||
|
||||
const mockClient = {
|
||||
session: {
|
||||
messages: mock(() => Promise.resolve({ data: [] })),
|
||||
summarize: mock(() => summarizePromise),
|
||||
revert: mock(() => Promise.resolve()),
|
||||
promptAsync: mock(() => Promise.resolve()),
|
||||
},
|
||||
tui: {
|
||||
showToast: mock(() => Promise.resolve()),
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
const { createAnthropicContextWindowLimitRecoveryHook } = await import("./recovery-hook")
|
||||
const ctx = { client: mockClient, directory: "/tmp" } as PluginInput
|
||||
const hook = createAnthropicContextWindowLimitRecoveryHook(ctx, { experimental })
|
||||
|
||||
// first error triggers compaction (setTimeout runs immediately due to mock)
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID: "session-96", error: "prompt is too long" },
|
||||
},
|
||||
})
|
||||
|
||||
//#when - second error while compaction is in progress
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID: "session-96", error: "prompt is too long" },
|
||||
},
|
||||
})
|
||||
|
||||
//#then - deduplication recovery was called for the second error
|
||||
expect(attemptDeduplicationRecoveryMock).toHaveBeenCalledTimes(1)
|
||||
expect(attemptDeduplicationRecoveryMock.mock.calls[0]![0]).toBe("session-96")
|
||||
} finally {
|
||||
if (resolveSummarize) resolveSummarize()
|
||||
restoreTimeouts()
|
||||
}
|
||||
})
|
||||
|
||||
test("does not call deduplication when compaction is not in progress", async () => {
|
||||
//#given
|
||||
const mockClient = {
|
||||
session: {
|
||||
messages: mock(() => Promise.resolve({ data: [] })),
|
||||
summarize: mock(() => Promise.resolve()),
|
||||
revert: mock(() => Promise.resolve()),
|
||||
promptAsync: mock(() => Promise.resolve()),
|
||||
},
|
||||
tui: {
|
||||
showToast: mock(() => Promise.resolve()),
|
||||
},
|
||||
}
|
||||
|
||||
const { createAnthropicContextWindowLimitRecoveryHook } = await import("./recovery-hook")
|
||||
const ctx = { client: mockClient, directory: "/tmp" } as PluginInput
|
||||
const hook = createAnthropicContextWindowLimitRecoveryHook(ctx)
|
||||
|
||||
//#when - single error (no compaction in progress)
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID: "session-no-dedup", error: "some other error" },
|
||||
},
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(attemptDeduplicationRecoveryMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import * as originalExecutor from "./executor"
|
||||
import * as originalParser from "./parser"
|
||||
import * as originalLogger from "../../shared/logger"
|
||||
|
||||
const executeCompactMock = mock(async () => {})
|
||||
const getLastAssistantMock = mock(async () => ({
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-6",
|
||||
}))
|
||||
const parseAnthropicTokenLimitErrorMock = mock(() => ({
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-6",
|
||||
}))
|
||||
|
||||
mock.module("./executor", () => ({
|
||||
executeCompact: executeCompactMock,
|
||||
getLastAssistant: getLastAssistantMock,
|
||||
}))
|
||||
|
||||
mock.module("./parser", () => ({
|
||||
parseAnthropicTokenLimitError: parseAnthropicTokenLimitErrorMock,
|
||||
}))
|
||||
|
||||
mock.module("../../shared/logger", () => ({
|
||||
log: () => {},
|
||||
}))
|
||||
|
||||
afterAll(() => {
|
||||
mock.module("./executor", () => originalExecutor)
|
||||
mock.module("./parser", () => originalParser)
|
||||
mock.module("../../shared/logger", () => originalLogger)
|
||||
})
|
||||
|
||||
function createMockContext(): PluginInput {
|
||||
return {
|
||||
client: {
|
||||
session: {
|
||||
messages: mock(() => Promise.resolve({ data: [] })),
|
||||
},
|
||||
tui: {
|
||||
showToast: mock(() => Promise.resolve()),
|
||||
},
|
||||
},
|
||||
directory: "/tmp",
|
||||
} as PluginInput
|
||||
}
|
||||
|
||||
function setupDelayedTimeoutMocks(): {
|
||||
restore: () => void
|
||||
getClearTimeoutCalls: () => Array<ReturnType<typeof setTimeout>>
|
||||
} {
|
||||
const originalSetTimeout = globalThis.setTimeout
|
||||
const originalClearTimeout = globalThis.clearTimeout
|
||||
const clearTimeoutCalls: Array<ReturnType<typeof setTimeout>> = []
|
||||
let timeoutCounter = 0
|
||||
|
||||
globalThis.setTimeout = ((_: () => void, _delay?: number) => {
|
||||
timeoutCounter += 1
|
||||
return timeoutCounter as ReturnType<typeof setTimeout>
|
||||
}) as typeof setTimeout
|
||||
|
||||
globalThis.clearTimeout = ((timeoutID: ReturnType<typeof setTimeout>) => {
|
||||
clearTimeoutCalls.push(timeoutID)
|
||||
}) as typeof clearTimeout
|
||||
|
||||
return {
|
||||
restore: () => {
|
||||
globalThis.setTimeout = originalSetTimeout
|
||||
globalThis.clearTimeout = originalClearTimeout
|
||||
},
|
||||
getClearTimeoutCalls: () => clearTimeoutCalls,
|
||||
}
|
||||
}
|
||||
|
||||
describe("createAnthropicContextWindowLimitRecoveryHook", () => {
|
||||
beforeEach(() => {
|
||||
executeCompactMock.mockClear()
|
||||
getLastAssistantMock.mockClear()
|
||||
parseAnthropicTokenLimitErrorMock.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
test("cancels pending timer when session.idle handles compaction first", async () => {
|
||||
//#given
|
||||
const { restore, getClearTimeoutCalls } = setupDelayedTimeoutMocks()
|
||||
const { createAnthropicContextWindowLimitRecoveryHook } = await import("./recovery-hook")
|
||||
const hook = createAnthropicContextWindowLimitRecoveryHook(createMockContext())
|
||||
|
||||
try {
|
||||
//#when
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID: "session-race", error: "prompt is too long" },
|
||||
},
|
||||
})
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-race" },
|
||||
},
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(getClearTimeoutCalls()).toEqual([1 as ReturnType<typeof setTimeout>])
|
||||
expect(executeCompactMock).toHaveBeenCalledTimes(1)
|
||||
expect(executeCompactMock.mock.calls[0]?.[0]).toBe("session-race")
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,174 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { Client } from "./client"
|
||||
import type { AutoCompactState, ParsedTokenLimitError } from "./types"
|
||||
import type { ExperimentalConfig, OhMyOpenCodeConfig } from "../../config"
|
||||
import { parseAnthropicTokenLimitError } from "./parser"
|
||||
import { executeCompact, getLastAssistant } from "./executor"
|
||||
import { attemptDeduplicationRecovery } from "./deduplication-recovery"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
export interface AnthropicContextWindowLimitRecoveryOptions {
|
||||
experimental?: ExperimentalConfig
|
||||
pluginConfig: OhMyOpenCodeConfig
|
||||
}
|
||||
|
||||
function createRecoveryState(): AutoCompactState {
|
||||
return {
|
||||
pendingCompact: new Set<string>(),
|
||||
errorDataBySession: new Map<string, ParsedTokenLimitError>(),
|
||||
retryStateBySession: new Map(),
|
||||
truncateStateBySession: new Map(),
|
||||
emptyContentAttemptBySession: new Map(),
|
||||
compactionInProgress: new Set<string>(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function createAnthropicContextWindowLimitRecoveryHook(
|
||||
ctx: PluginInput,
|
||||
options?: AnthropicContextWindowLimitRecoveryOptions,
|
||||
) {
|
||||
const autoCompactState = createRecoveryState()
|
||||
const experimental = options?.experimental
|
||||
const pluginConfig = options?.pluginConfig!
|
||||
const pendingCompactionTimeoutBySession = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
const timeoutID = pendingCompactionTimeoutBySession.get(sessionInfo.id)
|
||||
if (timeoutID !== undefined) {
|
||||
clearTimeout(timeoutID)
|
||||
pendingCompactionTimeoutBySession.delete(sessionInfo.id)
|
||||
}
|
||||
|
||||
autoCompactState.pendingCompact.delete(sessionInfo.id)
|
||||
autoCompactState.errorDataBySession.delete(sessionInfo.id)
|
||||
autoCompactState.retryStateBySession.delete(sessionInfo.id)
|
||||
autoCompactState.truncateStateBySession.delete(sessionInfo.id)
|
||||
autoCompactState.emptyContentAttemptBySession.delete(sessionInfo.id)
|
||||
autoCompactState.compactionInProgress.delete(sessionInfo.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
log("[auto-compact] session.error received", { sessionID, error: props?.error })
|
||||
if (!sessionID) return
|
||||
|
||||
const parsed = parseAnthropicTokenLimitError(props?.error)
|
||||
log("[auto-compact] parsed result", { parsed, hasError: !!props?.error })
|
||||
if (parsed) {
|
||||
autoCompactState.pendingCompact.add(sessionID)
|
||||
autoCompactState.errorDataBySession.set(sessionID, parsed)
|
||||
|
||||
if (autoCompactState.compactionInProgress.has(sessionID)) {
|
||||
await attemptDeduplicationRecovery(sessionID, parsed, experimental, ctx.client)
|
||||
return
|
||||
}
|
||||
|
||||
const lastAssistant = await getLastAssistant(sessionID, ctx.client, ctx.directory)
|
||||
const providerID = parsed.providerID ?? (lastAssistant?.providerID as string | undefined)
|
||||
const modelID = parsed.modelID ?? (lastAssistant?.modelID as string | undefined)
|
||||
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Context Limit Hit",
|
||||
message: "Truncating large tool outputs and recovering...",
|
||||
variant: "warning" as const,
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
const timeoutID = setTimeout(() => {
|
||||
pendingCompactionTimeoutBySession.delete(sessionID)
|
||||
executeCompact(
|
||||
sessionID,
|
||||
{ providerID, modelID },
|
||||
autoCompactState,
|
||||
ctx.client as Client,
|
||||
ctx.directory,
|
||||
pluginConfig,
|
||||
experimental,
|
||||
)
|
||||
}, 300)
|
||||
|
||||
pendingCompactionTimeoutBySession.set(sessionID, timeoutID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "message.updated") {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = info?.sessionID as string | undefined
|
||||
|
||||
if (sessionID && info?.role === "assistant" && info.error) {
|
||||
log("[auto-compact] message.updated with error", { sessionID, error: info.error })
|
||||
const parsed = parseAnthropicTokenLimitError(info.error)
|
||||
log("[auto-compact] message.updated parsed result", { parsed })
|
||||
if (parsed) {
|
||||
parsed.providerID = info.providerID as string | undefined
|
||||
parsed.modelID = info.modelID as string | undefined
|
||||
autoCompactState.pendingCompact.add(sessionID)
|
||||
autoCompactState.errorDataBySession.set(sessionID, parsed)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
if (!autoCompactState.pendingCompact.has(sessionID)) return
|
||||
|
||||
const timeoutID = pendingCompactionTimeoutBySession.get(sessionID)
|
||||
if (timeoutID !== undefined) {
|
||||
clearTimeout(timeoutID)
|
||||
pendingCompactionTimeoutBySession.delete(sessionID)
|
||||
}
|
||||
|
||||
const errorData = autoCompactState.errorDataBySession.get(sessionID)
|
||||
const lastAssistant = await getLastAssistant(sessionID, ctx.client, ctx.directory)
|
||||
|
||||
if (lastAssistant?.summary === true) {
|
||||
autoCompactState.pendingCompact.delete(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
const providerID = errorData?.providerID ?? (lastAssistant?.providerID as string | undefined)
|
||||
const modelID = errorData?.modelID ?? (lastAssistant?.modelID as string | undefined)
|
||||
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Auto Compact",
|
||||
message: "Token limit exceeded. Attempting recovery...",
|
||||
variant: "warning" as const,
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
await executeCompact(
|
||||
sessionID,
|
||||
{ providerID, modelID },
|
||||
autoCompactState,
|
||||
ctx.client as Client,
|
||||
ctx.directory,
|
||||
pluginConfig,
|
||||
experimental,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
event: eventHandler,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { runAggressiveTruncationStrategy } from "./aggressive-truncation-strategy"
|
||||
export { runSummarizeRetryStrategy } from "./summarize-retry-strategy"
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { AutoCompactState, RetryState, TruncateState } from "./types"
|
||||
|
||||
export function getOrCreateRetryState(
|
||||
autoCompactState: AutoCompactState,
|
||||
sessionID: string,
|
||||
): RetryState {
|
||||
let state = autoCompactState.retryStateBySession.get(sessionID)
|
||||
if (!state) {
|
||||
state = { attempt: 0, lastAttemptTime: 0, firstAttemptTime: 0 }
|
||||
autoCompactState.retryStateBySession.set(sessionID, state)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
export function getOrCreateTruncateState(
|
||||
autoCompactState: AutoCompactState,
|
||||
sessionID: string,
|
||||
): TruncateState {
|
||||
let state = autoCompactState.truncateStateBySession.get(sessionID)
|
||||
if (!state) {
|
||||
state = { truncateAttempt: 0 }
|
||||
autoCompactState.truncateStateBySession.set(sessionID, state)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
export function clearSessionState(
|
||||
autoCompactState: AutoCompactState,
|
||||
sessionID: string,
|
||||
): void {
|
||||
autoCompactState.pendingCompact.delete(sessionID)
|
||||
autoCompactState.errorDataBySession.delete(sessionID)
|
||||
autoCompactState.retryStateBySession.delete(sessionID)
|
||||
autoCompactState.truncateStateBySession.delete(sessionID)
|
||||
autoCompactState.emptyContentAttemptBySession.delete(sessionID)
|
||||
autoCompactState.compactionInProgress.delete(sessionID)
|
||||
}
|
||||
|
||||
export function getEmptyContentAttempt(
|
||||
autoCompactState: AutoCompactState,
|
||||
sessionID: string,
|
||||
): number {
|
||||
return autoCompactState.emptyContentAttemptBySession.get(sessionID) ?? 0
|
||||
}
|
||||
|
||||
export function incrementEmptyContentAttempt(
|
||||
autoCompactState: AutoCompactState,
|
||||
sessionID: string,
|
||||
): number {
|
||||
const attempt = getEmptyContentAttempt(autoCompactState, sessionID)
|
||||
autoCompactState.emptyContentAttemptBySession.set(sessionID, attempt + 1)
|
||||
return attempt
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { MESSAGE_STORAGE, PART_STORAGE } from "../../shared"
|
||||
|
||||
export { MESSAGE_STORAGE as MESSAGE_STORAGE_DIR, PART_STORAGE as PART_STORAGE_DIR }
|
||||
|
||||
export const TRUNCATION_MESSAGE =
|
||||
"[TOOL RESULT TRUNCATED - Context limit exceeded. Original output was too large and has been truncated to recover the session. Please re-run this tool if you need the full output.]"
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test"
|
||||
import { describe, test, expect, mock, beforeEach, afterAll } from "bun:test"
|
||||
import { truncateUntilTargetTokens } from "./storage"
|
||||
import * as storage from "./storage"
|
||||
|
||||
@@ -11,6 +11,10 @@ mock.module("./storage", () => {
|
||||
}
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
mock.module("./storage", () => storage)
|
||||
})
|
||||
|
||||
describe("truncateUntilTargetTokens", () => {
|
||||
const sessionID = "test-session"
|
||||
|
||||
@@ -21,10 +25,10 @@ describe("truncateUntilTargetTokens", () => {
|
||||
truncateToolResult.mockReset()
|
||||
})
|
||||
|
||||
test("truncates only until target is reached", () => {
|
||||
test("truncates only until target is reached", async () => {
|
||||
const { findToolResultsBySize, truncateToolResult } = require("./storage")
|
||||
|
||||
// #given: Two tool results, each 1000 chars. Target reduction is 500 chars.
|
||||
// given: Two tool results, each 1000 chars. Target reduction is 500 chars.
|
||||
const results = [
|
||||
{ partPath: "path1", partId: "id1", messageID: "m1", toolName: "tool1", outputSize: 1000 },
|
||||
{ partPath: "path2", partId: "id2", messageID: "m2", toolName: "tool2", outputSize: 1000 },
|
||||
@@ -37,11 +41,11 @@ describe("truncateUntilTargetTokens", () => {
|
||||
originalSize: 1000
|
||||
}))
|
||||
|
||||
// #when: currentTokens=1000, maxTokens=1000, targetRatio=0.5 (target=500, reduce=500)
|
||||
// when: currentTokens=1000, maxTokens=1000, targetRatio=0.5 (target=500, reduce=500)
|
||||
// charsPerToken=1 for simplicity in test
|
||||
const result = truncateUntilTargetTokens(sessionID, 1000, 1000, 0.5, 1)
|
||||
const result = await truncateUntilTargetTokens(sessionID, 1000, 1000, 0.5, 1)
|
||||
|
||||
// #then: Should only truncate the first tool
|
||||
// then: Should only truncate the first tool
|
||||
expect(result.truncatedCount).toBe(1)
|
||||
expect(truncateToolResult).toHaveBeenCalledTimes(1)
|
||||
expect(truncateToolResult).toHaveBeenCalledWith("path1")
|
||||
@@ -49,10 +53,10 @@ describe("truncateUntilTargetTokens", () => {
|
||||
expect(result.sufficient).toBe(true)
|
||||
})
|
||||
|
||||
test("truncates all if target not reached", () => {
|
||||
test("truncates all if target not reached", async () => {
|
||||
const { findToolResultsBySize, truncateToolResult } = require("./storage")
|
||||
|
||||
// #given: Two tool results, each 100 chars. Target reduction is 500 chars.
|
||||
// given: Two tool results, each 100 chars. Target reduction is 500 chars.
|
||||
const results = [
|
||||
{ partPath: "path1", partId: "id1", messageID: "m1", toolName: "tool1", outputSize: 100 },
|
||||
{ partPath: "path2", partId: "id2", messageID: "m2", toolName: "tool2", outputSize: 100 },
|
||||
@@ -65,10 +69,10 @@ describe("truncateUntilTargetTokens", () => {
|
||||
originalSize: 100
|
||||
}))
|
||||
|
||||
// #when: reduce 500 chars
|
||||
const result = truncateUntilTargetTokens(sessionID, 1000, 1000, 0.5, 1)
|
||||
// when: reduce 500 chars
|
||||
const result = await truncateUntilTargetTokens(sessionID, 1000, 1000, 0.5, 1)
|
||||
|
||||
// #then: Should truncate both
|
||||
// then: Should truncate both
|
||||
expect(result.truncatedCount).toBe(2)
|
||||
expect(truncateToolResult).toHaveBeenCalledTimes(2)
|
||||
expect(result.totalBytesRemoved).toBe(200)
|
||||
|
||||
@@ -1,250 +1,18 @@
|
||||
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { getOpenCodeStorageDir } from "../../shared/data-path"
|
||||
export type { AggressiveTruncateResult, ToolResultInfo } from "./tool-part-types"
|
||||
|
||||
const OPENCODE_STORAGE = getOpenCodeStorageDir()
|
||||
const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message")
|
||||
const PART_STORAGE = join(OPENCODE_STORAGE, "part")
|
||||
export {
|
||||
countTruncatedResults,
|
||||
findLargestToolResult,
|
||||
findToolResultsBySize,
|
||||
getTotalToolOutputSize,
|
||||
truncateToolResult,
|
||||
} from "./tool-result-storage"
|
||||
|
||||
const TRUNCATION_MESSAGE =
|
||||
"[TOOL RESULT TRUNCATED - Context limit exceeded. Original output was too large and has been truncated to recover the session. Please re-run this tool if you need the full output.]"
|
||||
export {
|
||||
countTruncatedResultsFromSDK,
|
||||
findToolResultsBySizeFromSDK,
|
||||
getTotalToolOutputSizeFromSDK,
|
||||
truncateToolResultAsync,
|
||||
} from "./tool-result-storage-sdk"
|
||||
|
||||
interface StoredToolPart {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "tool"
|
||||
callID: string
|
||||
tool: string
|
||||
state: {
|
||||
status: "pending" | "running" | "completed" | "error"
|
||||
input: Record<string, unknown>
|
||||
output?: string
|
||||
error?: string
|
||||
time?: {
|
||||
start: number
|
||||
end?: number
|
||||
compacted?: number
|
||||
}
|
||||
}
|
||||
truncated?: boolean
|
||||
originalSize?: number
|
||||
}
|
||||
|
||||
export interface ToolResultInfo {
|
||||
partPath: string
|
||||
partId: string
|
||||
messageID: string
|
||||
toolName: string
|
||||
outputSize: number
|
||||
}
|
||||
|
||||
function getMessageDir(sessionID: string): string {
|
||||
if (!existsSync(MESSAGE_STORAGE)) return ""
|
||||
|
||||
const directPath = join(MESSAGE_STORAGE, sessionID)
|
||||
if (existsSync(directPath)) {
|
||||
return directPath
|
||||
}
|
||||
|
||||
for (const dir of readdirSync(MESSAGE_STORAGE)) {
|
||||
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
|
||||
if (existsSync(sessionPath)) {
|
||||
return sessionPath
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
function getMessageIds(sessionID: string): string[] {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
if (!messageDir || !existsSync(messageDir)) return []
|
||||
|
||||
const messageIds: string[] = []
|
||||
for (const file of readdirSync(messageDir)) {
|
||||
if (!file.endsWith(".json")) continue
|
||||
const messageId = file.replace(".json", "")
|
||||
messageIds.push(messageId)
|
||||
}
|
||||
|
||||
return messageIds
|
||||
}
|
||||
|
||||
export function findToolResultsBySize(sessionID: string): ToolResultInfo[] {
|
||||
const messageIds = getMessageIds(sessionID)
|
||||
const results: ToolResultInfo[] = []
|
||||
|
||||
for (const messageID of messageIds) {
|
||||
const partDir = join(PART_STORAGE, messageID)
|
||||
if (!existsSync(partDir)) continue
|
||||
|
||||
for (const file of readdirSync(partDir)) {
|
||||
if (!file.endsWith(".json")) continue
|
||||
try {
|
||||
const partPath = join(partDir, file)
|
||||
const content = readFileSync(partPath, "utf-8")
|
||||
const part = JSON.parse(content) as StoredToolPart
|
||||
|
||||
if (part.type === "tool" && part.state?.output && !part.truncated) {
|
||||
results.push({
|
||||
partPath,
|
||||
partId: part.id,
|
||||
messageID,
|
||||
toolName: part.tool,
|
||||
outputSize: part.state.output.length,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results.sort((a, b) => b.outputSize - a.outputSize)
|
||||
}
|
||||
|
||||
export function findLargestToolResult(sessionID: string): ToolResultInfo | null {
|
||||
const results = findToolResultsBySize(sessionID)
|
||||
return results.length > 0 ? results[0] : null
|
||||
}
|
||||
|
||||
export function truncateToolResult(partPath: string): {
|
||||
success: boolean
|
||||
toolName?: string
|
||||
originalSize?: number
|
||||
} {
|
||||
try {
|
||||
const content = readFileSync(partPath, "utf-8")
|
||||
const part = JSON.parse(content) as StoredToolPart
|
||||
|
||||
if (!part.state?.output) {
|
||||
return { success: false }
|
||||
}
|
||||
|
||||
const originalSize = part.state.output.length
|
||||
const toolName = part.tool
|
||||
|
||||
part.truncated = true
|
||||
part.originalSize = originalSize
|
||||
part.state.output = TRUNCATION_MESSAGE
|
||||
|
||||
if (!part.state.time) {
|
||||
part.state.time = { start: Date.now() }
|
||||
}
|
||||
part.state.time.compacted = Date.now()
|
||||
|
||||
writeFileSync(partPath, JSON.stringify(part, null, 2))
|
||||
|
||||
return { success: true, toolName, originalSize }
|
||||
} catch {
|
||||
return { success: false }
|
||||
}
|
||||
}
|
||||
|
||||
export function getTotalToolOutputSize(sessionID: string): number {
|
||||
const results = findToolResultsBySize(sessionID)
|
||||
return results.reduce((sum, r) => sum + r.outputSize, 0)
|
||||
}
|
||||
|
||||
export function countTruncatedResults(sessionID: string): number {
|
||||
const messageIds = getMessageIds(sessionID)
|
||||
let count = 0
|
||||
|
||||
for (const messageID of messageIds) {
|
||||
const partDir = join(PART_STORAGE, messageID)
|
||||
if (!existsSync(partDir)) continue
|
||||
|
||||
for (const file of readdirSync(partDir)) {
|
||||
if (!file.endsWith(".json")) continue
|
||||
try {
|
||||
const content = readFileSync(join(partDir, file), "utf-8")
|
||||
const part = JSON.parse(content)
|
||||
if (part.truncated === true) {
|
||||
count++
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
export interface AggressiveTruncateResult {
|
||||
success: boolean
|
||||
sufficient: boolean
|
||||
truncatedCount: number
|
||||
totalBytesRemoved: number
|
||||
targetBytesToRemove: number
|
||||
truncatedTools: Array<{ toolName: string; originalSize: number }>
|
||||
}
|
||||
|
||||
export function truncateUntilTargetTokens(
|
||||
sessionID: string,
|
||||
currentTokens: number,
|
||||
maxTokens: number,
|
||||
targetRatio: number = 0.8,
|
||||
charsPerToken: number = 4
|
||||
): AggressiveTruncateResult {
|
||||
const targetTokens = Math.floor(maxTokens * targetRatio)
|
||||
const tokensToReduce = currentTokens - targetTokens
|
||||
const charsToReduce = tokensToReduce * charsPerToken
|
||||
|
||||
if (tokensToReduce <= 0) {
|
||||
return {
|
||||
success: true,
|
||||
sufficient: true,
|
||||
truncatedCount: 0,
|
||||
totalBytesRemoved: 0,
|
||||
targetBytesToRemove: 0,
|
||||
truncatedTools: [],
|
||||
}
|
||||
}
|
||||
|
||||
const results = findToolResultsBySize(sessionID)
|
||||
|
||||
if (results.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
sufficient: false,
|
||||
truncatedCount: 0,
|
||||
totalBytesRemoved: 0,
|
||||
targetBytesToRemove: charsToReduce,
|
||||
truncatedTools: [],
|
||||
}
|
||||
}
|
||||
|
||||
let totalRemoved = 0
|
||||
let truncatedCount = 0
|
||||
const truncatedTools: Array<{ toolName: string; originalSize: number }> = []
|
||||
|
||||
for (const result of results) {
|
||||
const truncateResult = truncateToolResult(result.partPath)
|
||||
if (truncateResult.success) {
|
||||
truncatedCount++
|
||||
const removedSize = truncateResult.originalSize ?? result.outputSize
|
||||
totalRemoved += removedSize
|
||||
truncatedTools.push({
|
||||
toolName: truncateResult.toolName ?? result.toolName,
|
||||
originalSize: removedSize,
|
||||
})
|
||||
|
||||
if (totalRemoved >= charsToReduce) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sufficient = totalRemoved >= charsToReduce
|
||||
|
||||
return {
|
||||
success: truncatedCount > 0,
|
||||
sufficient,
|
||||
truncatedCount,
|
||||
totalBytesRemoved: totalRemoved,
|
||||
targetBytesToRemove: charsToReduce,
|
||||
truncatedTools,
|
||||
}
|
||||
}
|
||||
export { truncateUntilTargetTokens } from "./target-token-truncation"
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
import { runSummarizeRetryStrategy } from "./summarize-retry-strategy"
|
||||
import type { AutoCompactState, ParsedTokenLimitError, RetryState } from "./types"
|
||||
import type { OhMyOpenCodeConfig } from "../../config"
|
||||
|
||||
type TimeoutCall = {
|
||||
delay: number
|
||||
}
|
||||
|
||||
function createAutoCompactState(): AutoCompactState {
|
||||
return {
|
||||
pendingCompact: new Set<string>(),
|
||||
errorDataBySession: new Map<string, ParsedTokenLimitError>(),
|
||||
retryStateBySession: new Map<string, RetryState>(),
|
||||
truncateStateBySession: new Map(),
|
||||
emptyContentAttemptBySession: new Map(),
|
||||
compactionInProgress: new Set<string>(),
|
||||
}
|
||||
}
|
||||
|
||||
describe("runSummarizeRetryStrategy", () => {
|
||||
const sessionID = "ses_retry_timeout"
|
||||
const directory = "/tmp"
|
||||
let autoCompactState: AutoCompactState
|
||||
|
||||
const summarizeMock = mock(() => Promise.resolve())
|
||||
const showToastMock = mock(() => Promise.resolve())
|
||||
const client = {
|
||||
session: {
|
||||
summarize: summarizeMock,
|
||||
messages: mock(() => Promise.resolve({ data: [] })),
|
||||
promptAsync: mock(() => Promise.resolve()),
|
||||
revert: mock(() => Promise.resolve()),
|
||||
},
|
||||
tui: {
|
||||
showToast: showToastMock,
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
autoCompactState = createAutoCompactState()
|
||||
summarizeMock.mockReset()
|
||||
showToastMock.mockReset()
|
||||
summarizeMock.mockResolvedValue(undefined)
|
||||
showToastMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.setTimeout = originalSetTimeout
|
||||
})
|
||||
|
||||
const originalSetTimeout = globalThis.setTimeout
|
||||
|
||||
test("stops retries when total summarize timeout is exceeded", async () => {
|
||||
//#given
|
||||
autoCompactState.pendingCompact.add(sessionID)
|
||||
autoCompactState.errorDataBySession.set(sessionID, {
|
||||
currentTokens: 250000,
|
||||
maxTokens: 200000,
|
||||
errorType: "token_limit_exceeded",
|
||||
})
|
||||
autoCompactState.retryStateBySession.set(sessionID, {
|
||||
attempt: 1,
|
||||
lastAttemptTime: Date.now(),
|
||||
firstAttemptTime: Date.now() - 130000,
|
||||
})
|
||||
|
||||
//#when
|
||||
await runSummarizeRetryStrategy({
|
||||
sessionID,
|
||||
msg: { providerID: "anthropic", modelID: "claude-sonnet-4-6" },
|
||||
autoCompactState,
|
||||
client: client as never,
|
||||
directory,
|
||||
pluginConfig: {} as OhMyOpenCodeConfig,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(summarizeMock).not.toHaveBeenCalled()
|
||||
expect(autoCompactState.pendingCompact.has(sessionID)).toBe(false)
|
||||
expect(autoCompactState.errorDataBySession.has(sessionID)).toBe(false)
|
||||
expect(autoCompactState.retryStateBySession.has(sessionID)).toBe(false)
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
title: "Auto Compact Timed Out",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("caps retry delay by remaining total timeout window", async () => {
|
||||
//#given
|
||||
const timeoutCalls: TimeoutCall[] = []
|
||||
globalThis.setTimeout = ((_: (...args: unknown[]) => void, delay?: number) => {
|
||||
timeoutCalls.push({ delay: delay ?? 0 })
|
||||
return 1 as unknown as ReturnType<typeof setTimeout>
|
||||
}) as typeof setTimeout
|
||||
|
||||
autoCompactState.retryStateBySession.set(sessionID, {
|
||||
attempt: 1,
|
||||
lastAttemptTime: Date.now(),
|
||||
firstAttemptTime: Date.now() - 119700,
|
||||
})
|
||||
summarizeMock.mockRejectedValueOnce(new Error("rate limited"))
|
||||
|
||||
//#when
|
||||
await runSummarizeRetryStrategy({
|
||||
sessionID,
|
||||
msg: { providerID: "anthropic", modelID: "claude-sonnet-4-6" },
|
||||
autoCompactState,
|
||||
client: client as never,
|
||||
directory,
|
||||
pluginConfig: {} as OhMyOpenCodeConfig,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(timeoutCalls.length).toBe(1)
|
||||
expect(timeoutCalls[0]!.delay).toBeGreaterThan(0)
|
||||
expect(timeoutCalls[0]!.delay).toBeLessThanOrEqual(500)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { AutoCompactState } from "./types"
|
||||
import type { OhMyOpenCodeConfig } from "../../config"
|
||||
import { RETRY_CONFIG } from "./types"
|
||||
import type { Client } from "./client"
|
||||
import { clearSessionState, getEmptyContentAttempt, getOrCreateRetryState } from "./state"
|
||||
import { sanitizeEmptyMessagesBeforeSummarize } from "./message-builder"
|
||||
import { fixEmptyMessages } from "./empty-content-recovery"
|
||||
|
||||
import { resolveCompactionModel } from "../shared/compaction-model-resolver"
|
||||
|
||||
const SUMMARIZE_RETRY_TOTAL_TIMEOUT_MS = 120_000
|
||||
export async function runSummarizeRetryStrategy(params: {
|
||||
sessionID: string
|
||||
msg: Record<string, unknown>
|
||||
autoCompactState: AutoCompactState
|
||||
client: Client
|
||||
directory: string
|
||||
pluginConfig: OhMyOpenCodeConfig
|
||||
errorType?: string
|
||||
messageIndex?: number
|
||||
}): Promise<void> {
|
||||
const retryState = getOrCreateRetryState(params.autoCompactState, params.sessionID)
|
||||
const now = Date.now()
|
||||
|
||||
if (retryState.firstAttemptTime === 0) {
|
||||
retryState.firstAttemptTime = now
|
||||
}
|
||||
|
||||
const elapsedTimeMs = now - retryState.firstAttemptTime
|
||||
if (elapsedTimeMs >= SUMMARIZE_RETRY_TOTAL_TIMEOUT_MS) {
|
||||
clearSessionState(params.autoCompactState, params.sessionID)
|
||||
await params.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Auto Compact Timed Out",
|
||||
message: "Compaction retries exceeded the timeout window. Please start a new session.",
|
||||
variant: "error",
|
||||
duration: 5000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
if (params.errorType?.includes("non-empty content")) {
|
||||
const attempt = getEmptyContentAttempt(params.autoCompactState, params.sessionID)
|
||||
if (attempt < 3) {
|
||||
const fixed = await fixEmptyMessages({
|
||||
sessionID: params.sessionID,
|
||||
autoCompactState: params.autoCompactState,
|
||||
client: params.client,
|
||||
messageIndex: params.messageIndex,
|
||||
})
|
||||
if (fixed) {
|
||||
setTimeout(() => {
|
||||
void runSummarizeRetryStrategy(params)
|
||||
}, 500)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
await params.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Recovery Failed",
|
||||
message:
|
||||
"Max recovery attempts (3) reached for empty content error. Please start a new session.",
|
||||
variant: "error",
|
||||
duration: 10000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (Date.now() - retryState.lastAttemptTime > 300000) {
|
||||
retryState.attempt = 0
|
||||
retryState.firstAttemptTime = Date.now()
|
||||
params.autoCompactState.truncateStateBySession.delete(params.sessionID)
|
||||
}
|
||||
|
||||
if (retryState.attempt < RETRY_CONFIG.maxAttempts) {
|
||||
retryState.attempt++
|
||||
retryState.lastAttemptTime = Date.now()
|
||||
|
||||
const providerID = params.msg.providerID as string | undefined
|
||||
const modelID = params.msg.modelID as string | undefined
|
||||
|
||||
if (providerID && modelID) {
|
||||
try {
|
||||
await sanitizeEmptyMessagesBeforeSummarize(params.sessionID, params.client)
|
||||
|
||||
await params.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Auto Compact",
|
||||
message: `Summarizing session (attempt ${retryState.attempt}/${RETRY_CONFIG.maxAttempts})...`,
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
const { providerID: targetProviderID, modelID: targetModelID } = resolveCompactionModel(
|
||||
params.pluginConfig,
|
||||
params.sessionID,
|
||||
providerID,
|
||||
modelID
|
||||
)
|
||||
|
||||
const summarizeBody = { providerID: targetProviderID, modelID: targetModelID, auto: true }
|
||||
await params.client.session.summarize({
|
||||
path: { id: params.sessionID },
|
||||
body: summarizeBody as never,
|
||||
query: { directory: params.directory },
|
||||
})
|
||||
return
|
||||
} catch {
|
||||
const remainingTimeMs = SUMMARIZE_RETRY_TOTAL_TIMEOUT_MS - (Date.now() - retryState.firstAttemptTime)
|
||||
if (remainingTimeMs <= 0) {
|
||||
clearSessionState(params.autoCompactState, params.sessionID)
|
||||
await params.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Auto Compact Timed Out",
|
||||
message: "Compaction retries exceeded the timeout window. Please start a new session.",
|
||||
variant: "error",
|
||||
duration: 5000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
const delay =
|
||||
RETRY_CONFIG.initialDelayMs *
|
||||
Math.pow(RETRY_CONFIG.backoffFactor, retryState.attempt - 1)
|
||||
const cappedDelay = Math.min(delay, RETRY_CONFIG.maxDelayMs, remainingTimeMs)
|
||||
|
||||
setTimeout(() => {
|
||||
void runSummarizeRetryStrategy(params)
|
||||
}, cappedDelay)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
await params.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Summarize Skipped",
|
||||
message: "Missing providerID or modelID.",
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
clearSessionState(params.autoCompactState, params.sessionID)
|
||||
await params.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Auto Compact Failed",
|
||||
message: "All recovery attempts failed. Please start a new session.",
|
||||
variant: "error",
|
||||
duration: 5000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { AggressiveTruncateResult } from "./tool-part-types"
|
||||
import { findToolResultsBySize, truncateToolResult } from "./tool-result-storage"
|
||||
import { truncateToolResultAsync } from "./tool-result-storage-sdk"
|
||||
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
|
||||
import { normalizeSDKResponse } from "../../shared"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
interface SDKToolPart {
|
||||
id: string
|
||||
type: string
|
||||
tool?: string
|
||||
state?: {
|
||||
output?: string
|
||||
time?: { start?: number; end?: number; compacted?: number }
|
||||
}
|
||||
originalSize?: number
|
||||
}
|
||||
|
||||
interface SDKMessage {
|
||||
info?: { id?: string }
|
||||
parts?: SDKToolPart[]
|
||||
}
|
||||
|
||||
function calculateTargetBytesToRemove(
|
||||
currentTokens: number,
|
||||
maxTokens: number,
|
||||
targetRatio: number,
|
||||
charsPerToken: number
|
||||
): { tokensToReduce: number; targetBytesToRemove: number } {
|
||||
const targetTokens = Math.floor(maxTokens * targetRatio)
|
||||
const tokensToReduce = currentTokens - targetTokens
|
||||
const targetBytesToRemove = tokensToReduce * charsPerToken
|
||||
return { tokensToReduce, targetBytesToRemove }
|
||||
}
|
||||
|
||||
export async function truncateUntilTargetTokens(
|
||||
sessionID: string,
|
||||
currentTokens: number,
|
||||
maxTokens: number,
|
||||
targetRatio: number = 0.8,
|
||||
charsPerToken: number = 4,
|
||||
client?: OpencodeClient
|
||||
): Promise<AggressiveTruncateResult> {
|
||||
const { tokensToReduce, targetBytesToRemove } = calculateTargetBytesToRemove(
|
||||
currentTokens,
|
||||
maxTokens,
|
||||
targetRatio,
|
||||
charsPerToken
|
||||
)
|
||||
|
||||
if (tokensToReduce <= 0) {
|
||||
return {
|
||||
success: true,
|
||||
sufficient: true,
|
||||
truncatedCount: 0,
|
||||
totalBytesRemoved: 0,
|
||||
targetBytesToRemove: 0,
|
||||
truncatedTools: [],
|
||||
}
|
||||
}
|
||||
|
||||
if (client && isSqliteBackend()) {
|
||||
let toolPartsByKey = new Map<string, SDKToolPart>()
|
||||
try {
|
||||
const response = (await client.session.messages({
|
||||
path: { id: sessionID },
|
||||
})) as { data?: SDKMessage[] }
|
||||
const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true })
|
||||
toolPartsByKey = new Map<string, SDKToolPart>()
|
||||
|
||||
for (const message of messages) {
|
||||
const messageID = message.info?.id
|
||||
if (!messageID || !message.parts) continue
|
||||
for (const part of message.parts) {
|
||||
if (part.type !== "tool") continue
|
||||
toolPartsByKey.set(`${messageID}:${part.id}`, part)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toolPartsByKey = new Map<string, SDKToolPart>()
|
||||
}
|
||||
|
||||
const results: import("./tool-part-types").ToolResultInfo[] = []
|
||||
for (const [key, part] of toolPartsByKey) {
|
||||
if (part.type === "tool" && part.state?.output && !part.state?.time?.compacted && part.tool) {
|
||||
results.push({
|
||||
partPath: "",
|
||||
partId: part.id,
|
||||
messageID: key.split(":")[0],
|
||||
toolName: part.tool,
|
||||
outputSize: part.state.output.length,
|
||||
})
|
||||
}
|
||||
}
|
||||
results.sort((a, b) => b.outputSize - a.outputSize)
|
||||
|
||||
if (results.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
sufficient: false,
|
||||
truncatedCount: 0,
|
||||
totalBytesRemoved: 0,
|
||||
targetBytesToRemove,
|
||||
truncatedTools: [],
|
||||
}
|
||||
}
|
||||
|
||||
let totalRemoved = 0
|
||||
let truncatedCount = 0
|
||||
const truncatedTools: Array<{ toolName: string; originalSize: number }> = []
|
||||
|
||||
for (const result of results) {
|
||||
const part = toolPartsByKey.get(`${result.messageID}:${result.partId}`)
|
||||
if (!part) continue
|
||||
|
||||
const truncateResult = await truncateToolResultAsync(
|
||||
client,
|
||||
sessionID,
|
||||
result.messageID,
|
||||
result.partId,
|
||||
part
|
||||
)
|
||||
if (truncateResult.success) {
|
||||
truncatedCount++
|
||||
const removedSize = truncateResult.originalSize ?? result.outputSize
|
||||
totalRemoved += removedSize
|
||||
truncatedTools.push({
|
||||
toolName: truncateResult.toolName ?? result.toolName,
|
||||
originalSize: removedSize,
|
||||
})
|
||||
|
||||
if (totalRemoved >= targetBytesToRemove) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sufficient = totalRemoved >= targetBytesToRemove
|
||||
|
||||
return {
|
||||
success: truncatedCount > 0,
|
||||
sufficient,
|
||||
truncatedCount,
|
||||
totalBytesRemoved: totalRemoved,
|
||||
targetBytesToRemove,
|
||||
truncatedTools,
|
||||
}
|
||||
}
|
||||
|
||||
const results = findToolResultsBySize(sessionID)
|
||||
|
||||
if (results.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
sufficient: false,
|
||||
truncatedCount: 0,
|
||||
totalBytesRemoved: 0,
|
||||
targetBytesToRemove,
|
||||
truncatedTools: [],
|
||||
}
|
||||
}
|
||||
|
||||
let totalRemoved = 0
|
||||
let truncatedCount = 0
|
||||
const truncatedTools: Array<{ toolName: string; originalSize: number }> = []
|
||||
|
||||
for (const result of results) {
|
||||
const truncateResult = truncateToolResult(result.partPath)
|
||||
if (truncateResult.success) {
|
||||
truncatedCount++
|
||||
const removedSize = truncateResult.originalSize ?? result.outputSize
|
||||
totalRemoved += removedSize
|
||||
truncatedTools.push({
|
||||
toolName: truncateResult.toolName ?? result.toolName,
|
||||
originalSize: removedSize,
|
||||
})
|
||||
|
||||
if (totalRemoved >= targetBytesToRemove) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sufficient = totalRemoved >= targetBytesToRemove
|
||||
|
||||
return {
|
||||
success: truncatedCount > 0,
|
||||
sufficient,
|
||||
truncatedCount,
|
||||
totalBytesRemoved: totalRemoved,
|
||||
targetBytesToRemove,
|
||||
truncatedTools,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export interface StoredToolPart {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "tool"
|
||||
callID: string
|
||||
tool: string
|
||||
state: {
|
||||
status: "pending" | "running" | "completed" | "error"
|
||||
input: Record<string, unknown>
|
||||
output?: string
|
||||
error?: string
|
||||
time?: {
|
||||
start: number
|
||||
end?: number
|
||||
compacted?: number
|
||||
}
|
||||
}
|
||||
truncated?: boolean
|
||||
originalSize?: number
|
||||
}
|
||||
|
||||
export interface ToolResultInfo {
|
||||
partPath: string
|
||||
partId: string
|
||||
messageID: string
|
||||
toolName: string
|
||||
outputSize: number
|
||||
}
|
||||
|
||||
export interface AggressiveTruncateResult {
|
||||
success: boolean
|
||||
sufficient: boolean
|
||||
truncatedCount: number
|
||||
totalBytesRemoved: number
|
||||
targetBytesToRemove: number
|
||||
truncatedTools: Array<{ toolName: string; originalSize: number }>
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { TRUNCATION_MESSAGE } from "./storage-paths"
|
||||
import type { ToolResultInfo } from "./tool-part-types"
|
||||
import { patchPart } from "../../shared/opencode-http-api"
|
||||
import { log } from "../../shared/logger"
|
||||
import { normalizeSDKResponse } from "../../shared"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
interface SDKToolPart {
|
||||
id: string
|
||||
type: string
|
||||
callID?: string
|
||||
tool?: string
|
||||
state?: {
|
||||
status?: string
|
||||
input?: Record<string, unknown>
|
||||
output?: string
|
||||
error?: string
|
||||
time?: { start?: number; end?: number; compacted?: number }
|
||||
}
|
||||
}
|
||||
|
||||
interface SDKMessage {
|
||||
info?: { id?: string }
|
||||
parts?: SDKToolPart[]
|
||||
}
|
||||
|
||||
export async function findToolResultsBySizeFromSDK(
|
||||
client: OpencodeClient,
|
||||
sessionID: string
|
||||
): Promise<ToolResultInfo[]> {
|
||||
try {
|
||||
const response = await client.session.messages({ path: { id: sessionID } })
|
||||
const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true })
|
||||
const results: ToolResultInfo[] = []
|
||||
|
||||
for (const msg of messages) {
|
||||
const messageID = msg.info?.id
|
||||
if (!messageID || !msg.parts) continue
|
||||
|
||||
for (const part of msg.parts) {
|
||||
if (part.type === "tool" && part.state?.output && !part.state?.time?.compacted && part.tool) {
|
||||
results.push({
|
||||
partPath: "",
|
||||
partId: part.id,
|
||||
messageID,
|
||||
toolName: part.tool,
|
||||
outputSize: part.state.output.length,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results.sort((a, b) => b.outputSize - a.outputSize)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function truncateToolResultAsync(
|
||||
client: OpencodeClient,
|
||||
sessionID: string,
|
||||
messageID: string,
|
||||
partId: string,
|
||||
part: SDKToolPart
|
||||
): Promise<{ success: boolean; toolName?: string; originalSize?: number }> {
|
||||
if (!part.state?.output) return { success: false }
|
||||
|
||||
const originalSize = part.state.output.length
|
||||
const toolName = part.tool
|
||||
|
||||
const updatedPart: Record<string, unknown> = {
|
||||
...part,
|
||||
state: {
|
||||
...part.state,
|
||||
output: TRUNCATION_MESSAGE,
|
||||
time: {
|
||||
...(part.state.time ?? { start: Date.now() }),
|
||||
compacted: Date.now(),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
const patched = await patchPart(client, sessionID, messageID, partId, updatedPart)
|
||||
if (!patched) return { success: false }
|
||||
return { success: true, toolName, originalSize }
|
||||
} catch (error) {
|
||||
log("[context-window-recovery] truncateToolResultAsync failed", { error: String(error) })
|
||||
return { success: false }
|
||||
}
|
||||
}
|
||||
|
||||
export async function countTruncatedResultsFromSDK(
|
||||
client: OpencodeClient,
|
||||
sessionID: string
|
||||
): Promise<number> {
|
||||
try {
|
||||
const response = await client.session.messages({ path: { id: sessionID } })
|
||||
const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true })
|
||||
let count = 0
|
||||
|
||||
for (const msg of messages) {
|
||||
if (!msg.parts) continue
|
||||
for (const part of msg.parts) {
|
||||
if (part.type === "tool" && part.state?.time?.compacted) count++
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTotalToolOutputSizeFromSDK(
|
||||
client: OpencodeClient,
|
||||
sessionID: string
|
||||
): Promise<number> {
|
||||
const results = await findToolResultsBySizeFromSDK(client, sessionID)
|
||||
return results.reduce((sum, result) => sum + result.outputSize, 0)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
|
||||
import { getMessageIds } from "./message-storage-directory"
|
||||
import { PART_STORAGE_DIR, TRUNCATION_MESSAGE } from "./storage-paths"
|
||||
import type { StoredToolPart, ToolResultInfo } from "./tool-part-types"
|
||||
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
let hasLoggedTruncateWarning = false
|
||||
|
||||
export function findToolResultsBySize(sessionID: string): ToolResultInfo[] {
|
||||
const messageIds = getMessageIds(sessionID)
|
||||
const results: ToolResultInfo[] = []
|
||||
|
||||
for (const messageID of messageIds) {
|
||||
const partDir = join(PART_STORAGE_DIR, messageID)
|
||||
if (!existsSync(partDir)) continue
|
||||
|
||||
for (const file of readdirSync(partDir)) {
|
||||
if (!file.endsWith(".json")) continue
|
||||
try {
|
||||
const partPath = join(partDir, file)
|
||||
const content = readFileSync(partPath, "utf-8")
|
||||
const part = JSON.parse(content) as StoredToolPart
|
||||
|
||||
if (part.type === "tool" && part.state?.output && !part.truncated) {
|
||||
results.push({
|
||||
partPath,
|
||||
partId: part.id,
|
||||
messageID,
|
||||
toolName: part.tool,
|
||||
outputSize: part.state.output.length,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results.sort((a, b) => b.outputSize - a.outputSize)
|
||||
}
|
||||
|
||||
export function findLargestToolResult(sessionID: string): ToolResultInfo | null {
|
||||
const results = findToolResultsBySize(sessionID)
|
||||
return results.length > 0 ? results[0] : null
|
||||
}
|
||||
|
||||
export function truncateToolResult(partPath: string): {
|
||||
success: boolean
|
||||
toolName?: string
|
||||
originalSize?: number
|
||||
} {
|
||||
if (isSqliteBackend()) {
|
||||
if (!hasLoggedTruncateWarning) {
|
||||
log("[context-window-recovery] Disabled on SQLite backend: truncateToolResult")
|
||||
hasLoggedTruncateWarning = true
|
||||
}
|
||||
return { success: false }
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(partPath, "utf-8")
|
||||
const part = JSON.parse(content) as StoredToolPart
|
||||
|
||||
if (!part.state?.output) {
|
||||
return { success: false }
|
||||
}
|
||||
|
||||
const originalSize = part.state.output.length
|
||||
const toolName = part.tool
|
||||
|
||||
part.truncated = true
|
||||
part.originalSize = originalSize
|
||||
part.state.output = TRUNCATION_MESSAGE
|
||||
|
||||
if (!part.state.time) {
|
||||
part.state.time = { start: Date.now() }
|
||||
}
|
||||
part.state.time.compacted = Date.now()
|
||||
|
||||
writeFileSync(partPath, JSON.stringify(part, null, 2))
|
||||
|
||||
return { success: true, toolName, originalSize }
|
||||
} catch {
|
||||
return { success: false }
|
||||
}
|
||||
}
|
||||
|
||||
export function getTotalToolOutputSize(sessionID: string): number {
|
||||
const results = findToolResultsBySize(sessionID)
|
||||
return results.reduce((sum, result) => sum + result.outputSize, 0)
|
||||
}
|
||||
|
||||
export function countTruncatedResults(sessionID: string): number {
|
||||
const messageIds = getMessageIds(sessionID)
|
||||
let count = 0
|
||||
|
||||
for (const messageID of messageIds) {
|
||||
const partDir = join(PART_STORAGE_DIR, messageID)
|
||||
if (!existsSync(partDir)) continue
|
||||
|
||||
for (const file of readdirSync(partDir)) {
|
||||
if (!file.endsWith(".json")) continue
|
||||
try {
|
||||
const content = readFileSync(join(partDir, file), "utf-8")
|
||||
const part = JSON.parse(content)
|
||||
if (part.truncated === true) {
|
||||
count++
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export interface ParsedTokenLimitError {
|
||||
export interface RetryState {
|
||||
attempt: number
|
||||
lastAttemptTime: number
|
||||
firstAttemptTime: number
|
||||
}
|
||||
|
||||
export interface TruncateState {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { log, normalizeModelID } from "../../shared"
|
||||
|
||||
const OPUS_4_6_PATTERN = /claude-opus-4[-.]6/i
|
||||
|
||||
function isClaudeProvider(providerID: string, modelID: string): boolean {
|
||||
if (["anthropic", "google-vertex-anthropic", "opencode"].includes(providerID)) return true
|
||||
if (providerID === "github-copilot" && modelID.toLowerCase().includes("claude")) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function isOpus46(modelID: string): boolean {
|
||||
const normalized = normalizeModelID(modelID)
|
||||
return OPUS_4_6_PATTERN.test(normalized)
|
||||
}
|
||||
|
||||
interface ChatParamsInput {
|
||||
sessionID: string
|
||||
agent: { name?: string }
|
||||
model: { providerID: string; modelID: string }
|
||||
provider: { id: string }
|
||||
message: { variant?: string }
|
||||
}
|
||||
|
||||
interface ChatParamsOutput {
|
||||
temperature?: number
|
||||
topP?: number
|
||||
topK?: number
|
||||
options: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function createAnthropicEffortHook() {
|
||||
return {
|
||||
"chat.params": async (
|
||||
input: ChatParamsInput,
|
||||
output: ChatParamsOutput
|
||||
): Promise<void> => {
|
||||
const { model, message } = input
|
||||
if (!model?.modelID || !model?.providerID) return
|
||||
if (message.variant !== "max") return
|
||||
if (!isClaudeProvider(model.providerID, model.modelID)) return
|
||||
if (!isOpus46(model.modelID)) return
|
||||
if (output.options.effort !== undefined) return
|
||||
|
||||
output.options.effort = "max"
|
||||
log("anthropic-effort: injected effort=max", {
|
||||
sessionID: input.sessionID,
|
||||
provider: model.providerID,
|
||||
model: model.modelID,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { createAnthropicEffortHook } from "./index"
|
||||
|
||||
interface ChatParamsInput {
|
||||
sessionID: string
|
||||
agent: { name?: string }
|
||||
model: { providerID: string; modelID: string; id?: string; api?: { npm?: string } }
|
||||
provider: { id: string }
|
||||
message: { variant?: string }
|
||||
}
|
||||
|
||||
interface ChatParamsOutput {
|
||||
temperature?: number
|
||||
topP?: number
|
||||
topK?: number
|
||||
options: Record<string, unknown>
|
||||
}
|
||||
|
||||
function createMockParams(overrides: {
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
variant?: string
|
||||
agentName?: string
|
||||
existingOptions?: Record<string, unknown>
|
||||
}): { input: ChatParamsInput; output: ChatParamsOutput } {
|
||||
const providerID = overrides.providerID ?? "anthropic"
|
||||
const modelID = overrides.modelID ?? "claude-opus-4-6"
|
||||
const variant = "variant" in overrides ? overrides.variant : "max"
|
||||
const agentName = overrides.agentName ?? "sisyphus"
|
||||
const existingOptions = overrides.existingOptions ?? {}
|
||||
|
||||
return {
|
||||
input: {
|
||||
sessionID: "test-session",
|
||||
agent: { name: agentName },
|
||||
model: { providerID, modelID },
|
||||
provider: { id: providerID },
|
||||
message: { variant },
|
||||
},
|
||||
output: {
|
||||
temperature: 0.1,
|
||||
options: { ...existingOptions },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("createAnthropicEffortHook", () => {
|
||||
describe("opus 4-6 with variant max", () => {
|
||||
it("should inject effort max for anthropic opus-4-6 with variant max", async () => {
|
||||
//#given anthropic opus-4-6 model with variant max
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({})
|
||||
|
||||
//#when chat.params hook is called
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
//#then effort should be injected into options
|
||||
expect(output.options.effort).toBe("max")
|
||||
})
|
||||
|
||||
it("should inject effort max for github-copilot claude-opus-4-6", async () => {
|
||||
//#given github-copilot provider with claude-opus-4-6
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({
|
||||
providerID: "github-copilot",
|
||||
modelID: "claude-opus-4-6",
|
||||
})
|
||||
|
||||
//#when chat.params hook is called
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
//#then effort should be injected (github-copilot resolves to anthropic)
|
||||
expect(output.options.effort).toBe("max")
|
||||
})
|
||||
|
||||
it("should inject effort max for opencode provider with claude-opus-4-6", async () => {
|
||||
//#given opencode provider with claude-opus-4-6
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({
|
||||
providerID: "opencode",
|
||||
modelID: "claude-opus-4-6",
|
||||
})
|
||||
|
||||
//#when chat.params hook is called
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
//#then effort should be injected
|
||||
expect(output.options.effort).toBe("max")
|
||||
})
|
||||
|
||||
it("should inject effort max for google-vertex-anthropic provider", async () => {
|
||||
//#given google-vertex-anthropic provider with claude-opus-4-6
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({
|
||||
providerID: "google-vertex-anthropic",
|
||||
modelID: "claude-opus-4-6",
|
||||
})
|
||||
|
||||
//#when chat.params hook is called
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
//#then effort should be injected
|
||||
expect(output.options.effort).toBe("max")
|
||||
})
|
||||
|
||||
it("should handle normalized model ID with dots (opus-4.6)", async () => {
|
||||
//#given model ID with dots instead of hyphens
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({
|
||||
modelID: "claude-opus-4.6",
|
||||
})
|
||||
|
||||
//#when chat.params hook is called
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
//#then should normalize and inject effort
|
||||
expect(output.options.effort).toBe("max")
|
||||
})
|
||||
})
|
||||
|
||||
describe("conditions NOT met - should skip", () => {
|
||||
it("should NOT inject effort when variant is not max", async () => {
|
||||
//#given opus-4-6 with variant high (not max)
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({ variant: "high" })
|
||||
|
||||
//#when chat.params hook is called
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
//#then effort should NOT be injected
|
||||
expect(output.options.effort).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should NOT inject effort when variant is undefined", async () => {
|
||||
//#given opus-4-6 with no variant
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({ variant: undefined })
|
||||
|
||||
//#when chat.params hook is called
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
//#then effort should NOT be injected
|
||||
expect(output.options.effort).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should NOT inject effort for non-opus model", async () => {
|
||||
//#given claude-sonnet-4-6 (not opus)
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({
|
||||
modelID: "claude-sonnet-4-6",
|
||||
})
|
||||
|
||||
//#when chat.params hook is called
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
//#then effort should NOT be injected
|
||||
expect(output.options.effort).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should NOT inject effort for non-anthropic provider with non-claude model", async () => {
|
||||
//#given openai provider with gpt model
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.4",
|
||||
})
|
||||
|
||||
//#when chat.params hook is called
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
//#then effort should NOT be injected
|
||||
expect(output.options.effort).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should NOT throw when model.modelID is undefined", async () => {
|
||||
//#given model with undefined modelID (runtime edge case)
|
||||
const hook = createAnthropicEffortHook()
|
||||
const input = {
|
||||
sessionID: "test-session",
|
||||
agent: { name: "sisyphus" },
|
||||
model: { providerID: "anthropic", modelID: undefined as unknown as string },
|
||||
provider: { id: "anthropic" },
|
||||
message: { variant: "max" as const },
|
||||
}
|
||||
const output = { temperature: 0.1, options: {} }
|
||||
|
||||
//#when chat.params hook is called with undefined modelID
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
//#then should gracefully skip without throwing
|
||||
expect(output.options.effort).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("preserves existing options", () => {
|
||||
it("should NOT overwrite existing effort if already set", async () => {
|
||||
//#given options already have effort set
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({
|
||||
existingOptions: { effort: "high" },
|
||||
})
|
||||
|
||||
//#when chat.params hook is called
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
//#then existing effort should be preserved
|
||||
expect(output.options.effort).toBe("high")
|
||||
})
|
||||
|
||||
it("should preserve other existing options when injecting effort", async () => {
|
||||
//#given options with existing thinking config
|
||||
const hook = createAnthropicEffortHook()
|
||||
const { input, output } = createMockParams({
|
||||
existingOptions: {
|
||||
thinking: { type: "enabled", budgetTokens: 31999 },
|
||||
},
|
||||
})
|
||||
|
||||
//#when chat.params hook is called
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
//#then effort should be added without affecting thinking
|
||||
expect(output.options.effort).toBe("max")
|
||||
expect(output.options.thinking).toEqual({
|
||||
type: "enabled",
|
||||
budgetTokens: 31999,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export { createAnthropicEffortHook } from "./hook";
|
||||
@@ -0,0 +1,64 @@
|
||||
# src/hooks/atlas/ — Master Boulder Orchestrator
|
||||
|
||||
**Generated:** 2026-03-06
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
17 files (~1976 LOC). The `atlasHook` — Continuation Tier hook that monitors session.idle events and forces continuation when boulder sessions (ralph-loop, task-spawned agents) have incomplete work. Also enforces write/edit policies for subagent sessions.
|
||||
|
||||
## WHAT ATLAS DOES
|
||||
|
||||
Atlas is the "keeper of sessions" — it tracks every session and decides:
|
||||
1. Should this session be forced to continue? (if boulder session with incomplete todos)
|
||||
2. Should write/edit be blocked? (policy enforcement for certain session types)
|
||||
3. Should a verification reminder be injected? (after tool execution)
|
||||
|
||||
## DECISION GATE (session.idle)
|
||||
|
||||
```
|
||||
session.idle event
|
||||
→ Is this a boulder/ralph/atlas session? (session-last-agent.ts)
|
||||
→ Is there an abort signal? (is-abort-error.ts)
|
||||
→ Failure count < max? (state.promptFailureCount)
|
||||
→ No running background tasks?
|
||||
→ Agent matches expected? (recent-model-resolver.ts)
|
||||
→ Plan complete? (todo status)
|
||||
→ Cooldown passed? (5s between injections)
|
||||
→ Inject continuation prompt (boulder-continuation-injector.ts)
|
||||
```
|
||||
|
||||
## KEY FILES
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `atlas-hook.ts` | `createAtlasHook()` — composes event + tool handlers, maintains session state |
|
||||
| `event-handler.ts` | `createAtlasEventHandler()` — decision gate for session.idle events |
|
||||
| `boulder-continuation-injector.ts` | Build + inject continuation prompt into session |
|
||||
| `system-reminder-templates.ts` | Templates for continuation reminder messages |
|
||||
| `tool-execute-before.ts` | Block write/edit based on session policy |
|
||||
| `tool-execute-after.ts` | Inject verification reminders post-tool |
|
||||
| `write-edit-tool-policy.ts` | Policy: which sessions can write/edit? |
|
||||
| `verification-reminders.ts` | Reminder content for verifying work |
|
||||
| `session-last-agent.ts` | Determine which agent owns the session |
|
||||
| `recent-model-resolver.ts` | Resolve model used in recent messages |
|
||||
| `subagent-session-id.ts` | Detect if session is a subagent session |
|
||||
| `sisyphus-path.ts` | Resolve `.sisyphus/` directory path |
|
||||
| `is-abort-error.ts` | Detect abort signals in session output |
|
||||
| `types.ts` | `SessionState`, `AtlasHookOptions`, `AtlasContext` |
|
||||
|
||||
## STATE PER SESSION
|
||||
|
||||
```typescript
|
||||
interface SessionState {
|
||||
promptFailureCount: number // Increments on failed continuations
|
||||
// Resets on successful continuation
|
||||
}
|
||||
```
|
||||
|
||||
Max consecutive failures before 5min pause: 5 (exponential backoff in todo-continuation-enforcer).
|
||||
|
||||
## RELATIONSHIP TO OTHER HOOKS
|
||||
|
||||
- **atlasHook** (Continuation Tier): Master orchestrator, handles boulder sessions
|
||||
- **todoContinuationEnforcer** (Continuation Tier): "Boulder" mechanism for main Sisyphus sessions
|
||||
- Both inject into session.idle but serve different session types
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { createAtlasEventHandler } from "./event-handler"
|
||||
import { createToolExecuteAfterHandler } from "./tool-execute-after"
|
||||
import { createToolExecuteBeforeHandler } from "./tool-execute-before"
|
||||
import type { AtlasHookOptions, PendingTaskRef, SessionState } from "./types"
|
||||
|
||||
export function createAtlasHook(ctx: PluginInput, options?: AtlasHookOptions) {
|
||||
const sessions = new Map<string, SessionState>()
|
||||
const pendingFilePaths = new Map<string, string>()
|
||||
const pendingTaskRefs = new Map<string, PendingTaskRef>()
|
||||
const autoCommit = options?.autoCommit ?? true
|
||||
|
||||
function getState(sessionID: string): SessionState {
|
||||
let state = sessions.get(sessionID)
|
||||
if (!state) {
|
||||
state = { promptFailureCount: 0 }
|
||||
sessions.set(sessionID, state)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
return {
|
||||
handler: createAtlasEventHandler({ ctx, options, sessions, getState }),
|
||||
"tool.execute.before": createToolExecuteBeforeHandler({ ctx, pendingFilePaths, pendingTaskRefs }),
|
||||
"tool.execute.after": createToolExecuteAfterHandler({ ctx, pendingFilePaths, pendingTaskRefs, autoCommit, getState }),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import { log } from "../../shared/logger"
|
||||
import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared"
|
||||
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"
|
||||
|
||||
export async function injectBoulderContinuation(input: {
|
||||
ctx: PluginInput
|
||||
sessionID: string
|
||||
planName: string
|
||||
remaining: number
|
||||
total: number
|
||||
agent?: string
|
||||
worktreePath?: string
|
||||
preferredTaskSessionId?: string
|
||||
preferredTaskTitle?: string
|
||||
backgroundManager?: BackgroundManager
|
||||
sessionState: SessionState
|
||||
}): Promise<void> {
|
||||
const {
|
||||
ctx,
|
||||
sessionID,
|
||||
planName,
|
||||
remaining,
|
||||
total,
|
||||
agent,
|
||||
worktreePath,
|
||||
preferredTaskSessionId,
|
||||
preferredTaskTitle,
|
||||
backgroundManager,
|
||||
sessionState,
|
||||
} = input
|
||||
|
||||
const hasRunningBgTasks = backgroundManager
|
||||
? backgroundManager.getTasksByParentSession(sessionID).some((t: { status: string }) => t.status === "running")
|
||||
: false
|
||||
|
||||
if (hasRunningBgTasks) {
|
||||
log(`[${HOOK_NAME}] Skipped injection: background tasks running`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const worktreeContext = worktreePath ? `\n\n[Worktree: ${worktreePath}]` : ""
|
||||
const preferredSessionContext = preferredTaskSessionId
|
||||
? `\n\n[Preferred reuse session for current top-level plan task${preferredTaskTitle ? `: ${preferredTaskTitle}` : ""}: ${preferredTaskSessionId}]`
|
||||
: ""
|
||||
const prompt =
|
||||
BOULDER_CONTINUATION_PROMPT.replace(/{PLAN_NAME}/g, planName) +
|
||||
`\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]` +
|
||||
preferredSessionContext +
|
||||
worktreeContext
|
||||
|
||||
try {
|
||||
log(`[${HOOK_NAME}] Injecting boulder continuation`, { sessionID, planName, remaining })
|
||||
|
||||
const promptContext = await resolveRecentPromptContextForSession(ctx, sessionID)
|
||||
const inheritedTools = resolveInheritedPromptTools(sessionID, promptContext.tools)
|
||||
|
||||
await ctx.client.session.promptAsync({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: agent ?? "atlas",
|
||||
...(promptContext.model !== undefined ? { model: promptContext.model } : {}),
|
||||
...(inheritedTools ? { tools: inheritedTools } : {}),
|
||||
parts: [createInternalAgentTextPart(prompt)],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
|
||||
sessionState.promptFailureCount = 0
|
||||
log(`[${HOOK_NAME}] Boulder continuation injected`, { sessionID })
|
||||
} catch (err) {
|
||||
sessionState.promptFailureCount += 1
|
||||
sessionState.lastFailureAt = Date.now()
|
||||
log(`[${HOOK_NAME}] Boulder continuation failed`, {
|
||||
sessionID,
|
||||
error: String(err),
|
||||
promptFailureCount: sessionState.promptFailureCount,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { log } from "../../shared/logger"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
|
||||
export async function isSessionInBoulderLineage(input: {
|
||||
client: PluginInput["client"]
|
||||
sessionID: string
|
||||
boulderSessionIDs: string[]
|
||||
}): Promise<boolean> {
|
||||
const visitedSessionIDs = new Set<string>()
|
||||
let currentSessionID = input.sessionID
|
||||
|
||||
while (!visitedSessionIDs.has(currentSessionID)) {
|
||||
visitedSessionIDs.add(currentSessionID)
|
||||
|
||||
const sessionResult = await input.client.session
|
||||
.get({ path: { id: currentSessionID } })
|
||||
.catch((error: unknown) => {
|
||||
log(`[${HOOK_NAME}] Failed to resolve session lineage`, {
|
||||
sessionID: input.sessionID,
|
||||
currentSessionID,
|
||||
error,
|
||||
})
|
||||
return null
|
||||
})
|
||||
|
||||
if (!sessionResult || sessionResult.error) {
|
||||
return false
|
||||
}
|
||||
|
||||
const parentSessionID = sessionResult.data?.parentID
|
||||
if (!parentSessionID) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (input.boulderSessionIDs.includes(parentSessionID)) {
|
||||
return true
|
||||
}
|
||||
|
||||
currentSessionID = parentSessionID
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
declare const require: (name: string) => any
|
||||
const { afterEach, beforeEach, describe, expect, mock, test } = require("bun:test")
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { randomUUID } from "node:crypto"
|
||||
|
||||
import { clearBoulderState, writeBoulderState } from "../../features/boulder-state"
|
||||
import { _resetForTesting } from "../../features/claude-code-session-state"
|
||||
import type { BoulderState } from "../../features/boulder-state"
|
||||
|
||||
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-compaction-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 directory = join(TEST_MESSAGE_STORAGE, sessionID)
|
||||
return existsSync(directory) ? directory : null
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("../../shared/opencode-storage-detection", () => ({
|
||||
isSqliteBackend: () => false,
|
||||
}))
|
||||
|
||||
const { createAtlasHook } = await import("./index")
|
||||
|
||||
describe("atlas hook compaction agent filtering", () => {
|
||||
let testDirectory: string
|
||||
|
||||
function createMockPluginInput() {
|
||||
const promptMock = mock(() => Promise.resolve())
|
||||
return {
|
||||
directory: testDirectory,
|
||||
client: {
|
||||
session: {
|
||||
prompt: promptMock,
|
||||
promptAsync: promptMock,
|
||||
},
|
||||
},
|
||||
_promptMock: promptMock,
|
||||
} as Parameters<typeof createAtlasHook>[0] & { _promptMock: ReturnType<typeof mock> }
|
||||
}
|
||||
|
||||
function writeMessage(sessionID: string, fileName: string, agent: string): void {
|
||||
const messageDir = join(TEST_MESSAGE_STORAGE, sessionID)
|
||||
mkdirSync(messageDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(messageDir, fileName),
|
||||
JSON.stringify({
|
||||
agent,
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
testDirectory = join(tmpdir(), `atlas-compaction-test-${randomUUID()}`)
|
||||
mkdirSync(testDirectory, { recursive: true })
|
||||
clearBoulderState(testDirectory)
|
||||
_resetForTesting()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearBoulderState(testDirectory)
|
||||
rmSync(testDirectory, { recursive: true, force: true })
|
||||
_resetForTesting()
|
||||
})
|
||||
|
||||
test("should inject continuation when the latest message is compaction but the previous agent matches atlas", async () => {
|
||||
// given
|
||||
const sessionID = "main-session-after-compaction"
|
||||
const planPath = join(testDirectory, "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: [sessionID],
|
||||
plan_name: "test-plan",
|
||||
agent: "atlas",
|
||||
}
|
||||
writeBoulderState(testDirectory, state)
|
||||
writeMessage(sessionID, "msg_001.json", "atlas")
|
||||
writeMessage(sessionID, "msg_002.json", "compaction")
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput)
|
||||
|
||||
// when
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID },
|
||||
},
|
||||
})
|
||||
|
||||
// then
|
||||
expect(mockInput._promptMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { log } from "../../shared/logger"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
import { isAbortError } from "./is-abort-error"
|
||||
import { handleAtlasSessionIdle } from "./idle-event"
|
||||
import type { AtlasHookOptions, SessionState } from "./types"
|
||||
|
||||
export function createAtlasEventHandler(input: {
|
||||
ctx: PluginInput
|
||||
options?: AtlasHookOptions
|
||||
sessions: Map<string, SessionState>
|
||||
getState: (sessionID: string) => SessionState
|
||||
}): (arg: { event: { type: string; properties?: unknown } }) => Promise<void> {
|
||||
const { ctx, options, sessions, getState } = input
|
||||
|
||||
return async ({ event }): Promise<void> => {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
const state = getState(sessionID)
|
||||
const isAbort = isAbortError(props?.error)
|
||||
state.lastEventWasAbortError = isAbort
|
||||
|
||||
log(`[${HOOK_NAME}] session.error`, { sessionID, isAbort })
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
await handleAtlasSessionIdle({ ctx, options, getState, sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "message.updated") {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = info?.sessionID as string | undefined
|
||||
const role = info?.role as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
const state = sessions.get(sessionID)
|
||||
if (state) {
|
||||
state.lastEventWasAbortError = false
|
||||
if (role === "user") {
|
||||
state.waitingForFinalWaveApproval = false
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "message.part.updated") {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = info?.sessionID as string | undefined
|
||||
const role = info?.role as string | undefined
|
||||
|
||||
if (sessionID && role === "assistant") {
|
||||
const state = sessions.get(sessionID)
|
||||
if (state) {
|
||||
state.lastEventWasAbortError = false
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (sessionID) {
|
||||
const state = sessions.get(sessionID)
|
||||
if (state) {
|
||||
state.lastEventWasAbortError = false
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
const deletedState = sessions.get(sessionInfo.id)
|
||||
if (deletedState?.pendingRetryTimer) {
|
||||
clearTimeout(deletedState.pendingRetryTimer)
|
||||
}
|
||||
sessions.delete(sessionInfo.id)
|
||||
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID ?? (props?.info as { id?: string } | undefined)?.id) as string | undefined
|
||||
if (sessionID) {
|
||||
const compactedState = sessions.get(sessionID)
|
||||
if (compactedState?.pendingRetryTimer) {
|
||||
clearTimeout(compactedState.pendingRetryTimer)
|
||||
}
|
||||
sessions.delete(sessionID)
|
||||
log(`[${HOOK_NAME}] Session compacted: cleaned up`, { sessionID })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
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"
|
||||
import { join } from "node:path"
|
||||
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-regression-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,
|
||||
}))
|
||||
|
||||
const { createAtlasHook } = await import("./index")
|
||||
const { MESSAGE_STORAGE } = await import("../../features/hook-message-injector")
|
||||
|
||||
type AtlasHookContext = Parameters<typeof createAtlasHook>[0]
|
||||
|
||||
describe("Atlas final-wave approval gate regressions", () => {
|
||||
let testDirectory = ""
|
||||
|
||||
function createMockPluginInput(): AtlasHookContext {
|
||||
const client = createOpencodeClient({ baseUrl: "http://localhost" })
|
||||
|
||||
Reflect.set(client.session, "prompt", async () => ({
|
||||
data: { info: {} as AssistantMessage, parts: [] },
|
||||
request: new Request("http://localhost/session/prompt"),
|
||||
response: new Response(),
|
||||
}))
|
||||
|
||||
Reflect.set(client.session, "promptAsync", async () => ({
|
||||
data: undefined,
|
||||
request: new Request("http://localhost/session/prompt_async"),
|
||||
response: new Response(),
|
||||
}))
|
||||
|
||||
Reflect.set(client.session, "get", async ({ path }: { path: { id: string } }) => {
|
||||
const parentID = path.id === "ses_nested_scope_review"
|
||||
? "atlas-nested-final-wave-session"
|
||||
: path.id.startsWith("ses_parallel_review_")
|
||||
? "atlas-parallel-final-wave-session"
|
||||
: "main-session-123"
|
||||
|
||||
return {
|
||||
data: {
|
||||
id: path.id,
|
||||
parentID,
|
||||
} as Session,
|
||||
request: new Request(`http://localhost/session/${path.id}`),
|
||||
response: new Response(),
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
directory: testDirectory,
|
||||
project: {} as AtlasHookContext["project"],
|
||||
worktree: testDirectory,
|
||||
serverUrl: new URL("http://localhost"),
|
||||
$: {} as AtlasHookContext["$"],
|
||||
client,
|
||||
}
|
||||
}
|
||||
|
||||
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-6" },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function writePlanState(sessionID: string, planName: string, planContent: string): void {
|
||||
const planPath = join(testDirectory, `${planName}.md`)
|
||||
writeFileSync(planPath, planContent)
|
||||
|
||||
const state: BoulderState = {
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [sessionID],
|
||||
plan_name: planName,
|
||||
agent: "atlas",
|
||||
}
|
||||
|
||||
writeBoulderState(testDirectory, state)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
testDirectory = join(tmpdir(), `atlas-final-wave-regression-${randomUUID()}`)
|
||||
mkdirSync(join(testDirectory, ".sisyphus"), { recursive: true })
|
||||
clearBoulderState(testDirectory)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearBoulderState(testDirectory)
|
||||
if (existsSync(testDirectory)) {
|
||||
rmSync(testDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("waits for approval when nested plan checkboxes remain but the only pending top-level task is final-wave", async () => {
|
||||
// given
|
||||
const sessionID = "atlas-nested-final-wave-session"
|
||||
setupMessageStorage(sessionID)
|
||||
writePlanState(sessionID, "nested-final-wave-plan", `# Plan
|
||||
|
||||
## TODOs
|
||||
- [x] 1. Implement feature
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- [ ] bun test src/feature.test.ts -> PASS
|
||||
|
||||
**Evidence to Capture**:
|
||||
- [ ] Each evidence file named: task-1-happy-path.txt
|
||||
|
||||
## Final Verification Wave (MANDATORY - after ALL implementation tasks)
|
||||
- [x] F1. **Plan Compliance Audit** - \`oracle\`
|
||||
- [x] F2. **Code Quality Review** - \`unspecified-high\`
|
||||
- [x] F3. **Real Manual QA** - \`unspecified-high\`
|
||||
- [ ] F4. **Scope Fidelity Check** - \`deep\`
|
||||
|
||||
## Final Checklist
|
||||
- [ ] All tests pass
|
||||
`)
|
||||
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
const toolOutput = {
|
||||
title: "Sisyphus Task",
|
||||
output: `Tasks [1/1 compliant] | Contamination [CLEAN] | Unaccounted [CLEAN] | VERDICT: APPROVE
|
||||
|
||||
<task_metadata>
|
||||
session_id: ses_nested_scope_review
|
||||
</task_metadata>`,
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
// when
|
||||
await hook["tool.execute.after"]({ tool: "task", sessionID }, toolOutput)
|
||||
|
||||
// then
|
||||
expect(toolOutput.output).toContain("FINAL WAVE APPROVAL GATE")
|
||||
expect(toolOutput.output).toContain("explicit user approval")
|
||||
expect(toolOutput.output).not.toContain("STEP 8: PROCEED TO NEXT TASK")
|
||||
})
|
||||
|
||||
test("waits for approval after the final parallel reviewer approves before plan checkboxes are updated", async () => {
|
||||
// given
|
||||
const sessionID = "atlas-parallel-final-wave-session"
|
||||
setupMessageStorage(sessionID)
|
||||
writePlanState(sessionID, "parallel-final-wave-plan", `# Plan
|
||||
|
||||
## TODOs
|
||||
- [x] 1. Ship implementation
|
||||
- [x] 2. Verify implementation
|
||||
|
||||
## Final Verification Wave (MANDATORY - after ALL implementation tasks)
|
||||
- [ ] F1. **Plan Compliance Audit** - \`oracle\`
|
||||
- [ ] F2. **Code Quality Review** - \`unspecified-high\`
|
||||
- [ ] F3. **Real Manual QA** - \`unspecified-high\`
|
||||
- [ ] F4. **Scope Fidelity Check** - \`deep\`
|
||||
`)
|
||||
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
const firstThreeOutputs = [1, 2, 3].map((index) => ({
|
||||
title: `Final review ${index}`,
|
||||
output: `Reviewer ${index} | VERDICT: APPROVE
|
||||
|
||||
<task_metadata>
|
||||
session_id: ses_parallel_review_${index}
|
||||
</task_metadata>`,
|
||||
metadata: {},
|
||||
}))
|
||||
const lastOutput = {
|
||||
title: "Final review 4",
|
||||
output: `Reviewer 4 | VERDICT: APPROVE
|
||||
|
||||
<task_metadata>
|
||||
session_id: ses_parallel_review_4
|
||||
</task_metadata>`,
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
// when
|
||||
for (const toolOutput of firstThreeOutputs) {
|
||||
await hook["tool.execute.after"]({ tool: "task", sessionID }, toolOutput)
|
||||
}
|
||||
await hook["tool.execute.after"]({ tool: "task", sessionID }, lastOutput)
|
||||
|
||||
// then
|
||||
for (const toolOutput of firstThreeOutputs) {
|
||||
expect(toolOutput.output).toContain("STEP 8: PROCEED TO NEXT TASK")
|
||||
expect(toolOutput.output).not.toContain("FINAL WAVE APPROVAL GATE")
|
||||
}
|
||||
expect(lastOutput.output).toContain("FINAL WAVE APPROVAL GATE")
|
||||
expect(lastOutput.output).toContain("explicit user approval")
|
||||
expect(lastOutput.output).not.toContain("STEP 8: PROCEED TO NEXT TASK")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,232 @@
|
||||
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"
|
||||
import { join } from "node:path"
|
||||
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,
|
||||
}))
|
||||
|
||||
const { createAtlasHook } = await import("./index")
|
||||
const { MESSAGE_STORAGE } = await import("../../features/hook-message-injector")
|
||||
|
||||
type AtlasHookContext = Parameters<typeof createAtlasHook>[0]
|
||||
type PromptMock = ReturnType<typeof mock>
|
||||
|
||||
describe("Atlas final verification approval gate", () => {
|
||||
let testDirectory = ""
|
||||
|
||||
function createMockPluginInput(): AtlasHookContext & { _promptMock: PromptMock } {
|
||||
const client = createOpencodeClient({ baseUrl: "http://localhost" })
|
||||
const promptMock = mock((input: unknown) => input)
|
||||
|
||||
Reflect.set(client.session, "prompt", async (input: unknown) => {
|
||||
promptMock(input)
|
||||
return {
|
||||
data: { info: {} as AssistantMessage, parts: [] },
|
||||
request: new Request("http://localhost/session/prompt"),
|
||||
response: new Response(),
|
||||
}
|
||||
})
|
||||
|
||||
Reflect.set(client.session, "promptAsync", async (input: unknown) => {
|
||||
promptMock(input)
|
||||
return {
|
||||
data: undefined,
|
||||
request: new Request("http://localhost/session/prompt_async"),
|
||||
response: new Response(),
|
||||
}
|
||||
})
|
||||
|
||||
Reflect.set(client.session, "get", async ({ path }: { path: { id: string } }) => {
|
||||
const parentID = path.id === "ses_final_wave_review"
|
||||
? "atlas-final-wave-session"
|
||||
: path.id === "ses_feature_task"
|
||||
? "atlas-non-final-session"
|
||||
: "main-session-123"
|
||||
return {
|
||||
data: {
|
||||
id: path.id,
|
||||
parentID,
|
||||
} as Session,
|
||||
request: new Request(`http://localhost/session/${path.id}`),
|
||||
response: new Response(),
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
directory: testDirectory,
|
||||
project: {} as AtlasHookContext["project"],
|
||||
worktree: testDirectory,
|
||||
serverUrl: new URL("http://localhost"),
|
||||
$: {} as AtlasHookContext["$"],
|
||||
client,
|
||||
_promptMock: promptMock,
|
||||
}
|
||||
}
|
||||
|
||||
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-6" },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
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 })
|
||||
clearBoulderState(testDirectory)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearBoulderState(testDirectory)
|
||||
if (existsSync(testDirectory)) {
|
||||
rmSync(testDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
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(
|
||||
planPath,
|
||||
`# Plan
|
||||
|
||||
## TODOs
|
||||
- [x] 1. Ship the implementation
|
||||
|
||||
## Final Verification Wave (MANDATORY - after ALL implementation tasks)
|
||||
- [x] F1. **Plan Compliance Audit** - \`oracle\`
|
||||
- [x] F2. **Code Quality Review** - \`unspecified-high\`
|
||||
- [x] F3. **Real Manual QA** - \`unspecified-high\`
|
||||
- [ ] F4. **Scope Fidelity Check** - \`deep\`
|
||||
`,
|
||||
)
|
||||
|
||||
const state: BoulderState = {
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [sessionID],
|
||||
plan_name: "final-wave-plan",
|
||||
agent: "atlas",
|
||||
}
|
||||
writeBoulderState(testDirectory, state)
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput)
|
||||
const toolOutput = {
|
||||
title: "Sisyphus Task",
|
||||
output: `Tasks [4/4 compliant] | Contamination [CLEAN] | Unaccounted [CLEAN] | VERDICT: APPROVE
|
||||
|
||||
<task_metadata>
|
||||
session_id: ses_final_wave_review
|
||||
</task_metadata>`,
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
// when
|
||||
await hook["tool.execute.after"]({ tool: "task", sessionID }, toolOutput)
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||
|
||||
// then
|
||||
expect(toolOutput.output).toContain("FINAL WAVE APPROVAL GATE")
|
||||
expect(toolOutput.output).toContain("explicit user approval")
|
||||
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(
|
||||
planPath,
|
||||
`# Plan
|
||||
|
||||
## TODOs
|
||||
- [x] 1. Setup
|
||||
- [ ] 2. Implement feature
|
||||
|
||||
## Final Verification Wave (MANDATORY - after ALL implementation tasks)
|
||||
- [ ] F1. **Plan Compliance Audit** - \`oracle\`
|
||||
- [ ] F2. **Code Quality Review** - \`unspecified-high\`
|
||||
- [ ] F3. **Real Manual QA** - \`unspecified-high\`
|
||||
- [ ] F4. **Scope Fidelity Check** - \`deep\`
|
||||
`,
|
||||
)
|
||||
|
||||
const state: BoulderState = {
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [sessionID],
|
||||
plan_name: "implementation-plan",
|
||||
agent: "atlas",
|
||||
}
|
||||
writeBoulderState(testDirectory, state)
|
||||
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
const toolOutput = {
|
||||
title: "Sisyphus Task",
|
||||
output: `Implementation finished successfully
|
||||
|
||||
<task_metadata>
|
||||
session_id: ses_feature_task
|
||||
</task_metadata>`,
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
// when
|
||||
await hook["tool.execute.after"]({ tool: "task", sessionID }, toolOutput)
|
||||
|
||||
// then
|
||||
expect(toolOutput.output).toContain("COMPLETION GATE")
|
||||
expect(toolOutput.output).toContain("STEP 8: PROCEED TO NEXT TASK")
|
||||
expect(toolOutput.output).not.toContain("FINAL WAVE APPROVAL GATE")
|
||||
|
||||
cleanupMessageStorage(sessionID)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { SessionState } from "./types"
|
||||
import { readFinalWavePlanState } from "./final-wave-plan-state"
|
||||
|
||||
const APPROVE_VERDICT_PATTERN = /\bVERDICT:\s*APPROVE\b/i
|
||||
|
||||
function clearFinalWaveApprovalTracking(sessionState: SessionState): void {
|
||||
sessionState.pendingFinalWaveTaskCount = undefined
|
||||
sessionState.approvedFinalWaveTaskCount = undefined
|
||||
}
|
||||
|
||||
export function shouldPauseForFinalWaveApproval(input: {
|
||||
planPath: string
|
||||
taskOutput: string
|
||||
sessionState: SessionState
|
||||
}): boolean {
|
||||
const planState = readFinalWavePlanState(input.planPath)
|
||||
if (!planState) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (planState.pendingImplementationTaskCount > 0 || planState.pendingFinalWaveTaskCount === 0) {
|
||||
clearFinalWaveApprovalTracking(input.sessionState)
|
||||
return false
|
||||
}
|
||||
|
||||
if (!APPROVE_VERDICT_PATTERN.test(input.taskOutput)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (planState.pendingFinalWaveTaskCount === 1) {
|
||||
clearFinalWaveApprovalTracking(input.sessionState)
|
||||
return true
|
||||
}
|
||||
|
||||
if (input.sessionState.pendingFinalWaveTaskCount !== planState.pendingFinalWaveTaskCount) {
|
||||
input.sessionState.pendingFinalWaveTaskCount = planState.pendingFinalWaveTaskCount
|
||||
input.sessionState.approvedFinalWaveTaskCount = 0
|
||||
}
|
||||
|
||||
input.sessionState.approvedFinalWaveTaskCount = (input.sessionState.approvedFinalWaveTaskCount ?? 0) + 1
|
||||
const shouldPause = input.sessionState.approvedFinalWaveTaskCount >= planState.pendingFinalWaveTaskCount
|
||||
if (shouldPause) {
|
||||
clearFinalWaveApprovalTracking(input.sessionState)
|
||||
}
|
||||
|
||||
return shouldPause
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
|
||||
const TODO_HEADING_PATTERN = /^##\s+TODOs\b/i
|
||||
const FINAL_VERIFICATION_HEADING_PATTERN = /^##\s+Final Verification Wave\b/i
|
||||
const SECOND_LEVEL_HEADING_PATTERN = /^##\s+/
|
||||
const UNCHECKED_CHECKBOX_PATTERN = /^\s*[-*]\s*\[\s*\]\s*(.+)$/
|
||||
const TODO_TASK_PATTERN = /^\d+\./
|
||||
const FINAL_WAVE_TASK_PATTERN = /^F\d+\./i
|
||||
|
||||
type PlanSection = "todo" | "final-wave" | "other"
|
||||
|
||||
export type FinalWavePlanState = {
|
||||
pendingImplementationTaskCount: number
|
||||
pendingFinalWaveTaskCount: number
|
||||
}
|
||||
|
||||
export function readFinalWavePlanState(planPath: string): FinalWavePlanState | null {
|
||||
if (!existsSync(planPath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(planPath, "utf-8")
|
||||
const lines = content.split(/\r?\n/)
|
||||
let section: PlanSection = "other"
|
||||
let pendingImplementationTaskCount = 0
|
||||
let pendingFinalWaveTaskCount = 0
|
||||
|
||||
for (const line of lines) {
|
||||
if (SECOND_LEVEL_HEADING_PATTERN.test(line)) {
|
||||
section = TODO_HEADING_PATTERN.test(line)
|
||||
? "todo"
|
||||
: FINAL_VERIFICATION_HEADING_PATTERN.test(line)
|
||||
? "final-wave"
|
||||
: "other"
|
||||
}
|
||||
|
||||
const uncheckedTaskMatch = line.match(UNCHECKED_CHECKBOX_PATTERN)
|
||||
if (!uncheckedTaskMatch) {
|
||||
continue
|
||||
}
|
||||
|
||||
const taskLabel = uncheckedTaskMatch[1].trim()
|
||||
if (section === "todo" && TODO_TASK_PATTERN.test(taskLabel)) {
|
||||
pendingImplementationTaskCount += 1
|
||||
}
|
||||
|
||||
if (section === "final-wave" && FINAL_WAVE_TASK_PATTERN.test(taskLabel)) {
|
||||
pendingFinalWaveTaskCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
pendingImplementationTaskCount,
|
||||
pendingFinalWaveTaskCount,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const HOOK_NAME = "atlas"
|
||||
@@ -0,0 +1,122 @@
|
||||
import { afterEach, beforeEach, describe, it } from "bun:test"
|
||||
import assert from "node:assert/strict"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { clearBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state"
|
||||
import type { BoulderState } from "../../features/boulder-state"
|
||||
import { _resetForTesting, subagentSessions } from "../../features/claude-code-session-state"
|
||||
|
||||
const { createAtlasHook } = await import("./index")
|
||||
|
||||
describe("atlas hook idle-event session lineage", () => {
|
||||
const MAIN_SESSION_ID = "main-session-123"
|
||||
|
||||
let testDirectory = ""
|
||||
let promptCalls: Array<unknown> = []
|
||||
|
||||
function writeIncompleteBoulder(): void {
|
||||
const planPath = join(testDirectory, "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(testDirectory, state)
|
||||
}
|
||||
|
||||
function createHook(parentSessionIDs?: Record<string, string | undefined>) {
|
||||
return createAtlasHook({
|
||||
directory: testDirectory,
|
||||
client: {
|
||||
session: {
|
||||
get: async (input: { path: { id: string } }) => ({
|
||||
data: {
|
||||
parentID: parentSessionIDs?.[input.path.id],
|
||||
},
|
||||
}),
|
||||
messages: async () => ({ data: [] }),
|
||||
prompt: async (input: unknown) => {
|
||||
promptCalls.push(input)
|
||||
return { data: {} }
|
||||
},
|
||||
promptAsync: async (input: unknown) => {
|
||||
promptCalls.push(input)
|
||||
return { data: {} }
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createAtlasHook>[0])
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
testDirectory = join(tmpdir(), `atlas-idle-lineage-${randomUUID()}`)
|
||||
if (!existsSync(testDirectory)) {
|
||||
mkdirSync(testDirectory, { recursive: true })
|
||||
}
|
||||
|
||||
promptCalls = []
|
||||
clearBoulderState(testDirectory)
|
||||
_resetForTesting()
|
||||
subagentSessions.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearBoulderState(testDirectory)
|
||||
if (existsSync(testDirectory)) {
|
||||
rmSync(testDirectory, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
_resetForTesting()
|
||||
})
|
||||
|
||||
it("does not append unrelated subagent sessions during idle", async () => {
|
||||
const unrelatedSubagentSessionID = "subagent-session-unrelated"
|
||||
const unrelatedParentSessionID = "unrelated-parent-session"
|
||||
|
||||
writeIncompleteBoulder()
|
||||
subagentSessions.add(unrelatedSubagentSessionID)
|
||||
|
||||
const hook = createHook({
|
||||
[unrelatedSubagentSessionID]: unrelatedParentSessionID,
|
||||
})
|
||||
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: unrelatedSubagentSessionID },
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(readBoulderState(testDirectory)?.session_ids.includes(unrelatedSubagentSessionID), false)
|
||||
assert.equal(promptCalls.length, 0)
|
||||
})
|
||||
|
||||
it("appends boulder-owned subagent sessions during idle when lineage reaches tracked session", async () => {
|
||||
const subagentSessionID = "subagent-session-456"
|
||||
const intermediateParentSessionID = "subagent-parent-789"
|
||||
|
||||
writeIncompleteBoulder()
|
||||
subagentSessions.add(subagentSessionID)
|
||||
|
||||
const hook = createHook({
|
||||
[subagentSessionID]: intermediateParentSessionID,
|
||||
[intermediateParentSessionID]: MAIN_SESSION_ID,
|
||||
})
|
||||
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: subagentSessionID },
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(readBoulderState(testDirectory)?.session_ids.includes(subagentSessionID), true)
|
||||
assert.equal(promptCalls.length, 1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,199 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import {
|
||||
getPlanProgress,
|
||||
getTaskSessionState,
|
||||
readBoulderState,
|
||||
readCurrentTopLevelTask,
|
||||
} from "../../features/boulder-state"
|
||||
import { log } from "../../shared/logger"
|
||||
import { injectBoulderContinuation } from "./boulder-continuation-injector"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
import { resolveActiveBoulderSession } from "./resolve-active-boulder-session"
|
||||
import type { AtlasHookOptions, SessionState } from "./types"
|
||||
|
||||
const CONTINUATION_COOLDOWN_MS = 5000
|
||||
const FAILURE_BACKOFF_MS = 5 * 60 * 1000
|
||||
const MAX_CONSECUTIVE_PROMPT_FAILURES = 10
|
||||
const RETRY_DELAY_MS = CONTINUATION_COOLDOWN_MS + 1000
|
||||
|
||||
function hasRunningBackgroundTasks(sessionID: string, options?: AtlasHookOptions): boolean {
|
||||
const backgroundManager = options?.backgroundManager
|
||||
return backgroundManager
|
||||
? backgroundManager.getTasksByParentSession(sessionID).some((task: { status: string }) => task.status === "running")
|
||||
: false
|
||||
}
|
||||
|
||||
async function injectContinuation(input: {
|
||||
ctx: PluginInput
|
||||
sessionID: string
|
||||
sessionState: SessionState
|
||||
options?: AtlasHookOptions
|
||||
planName: string
|
||||
progress: { total: number; completed: number }
|
||||
agent?: string
|
||||
worktreePath?: string
|
||||
}): Promise<void> {
|
||||
const remaining = input.progress.total - input.progress.completed
|
||||
input.sessionState.lastContinuationInjectedAt = Date.now()
|
||||
|
||||
try {
|
||||
const currentBoulder = readBoulderState(input.ctx.directory)
|
||||
const currentTask = currentBoulder
|
||||
? readCurrentTopLevelTask(currentBoulder.active_plan)
|
||||
: null
|
||||
const preferredTaskSession = currentTask
|
||||
? getTaskSessionState(input.ctx.directory, currentTask.key)
|
||||
: null
|
||||
|
||||
await injectBoulderContinuation({
|
||||
ctx: input.ctx,
|
||||
sessionID: input.sessionID,
|
||||
planName: input.planName,
|
||||
remaining,
|
||||
total: input.progress.total,
|
||||
agent: input.agent,
|
||||
worktreePath: input.worktreePath,
|
||||
preferredTaskSessionId: preferredTaskSession?.session_id,
|
||||
preferredTaskTitle: preferredTaskSession?.task_title,
|
||||
backgroundManager: input.options?.backgroundManager,
|
||||
sessionState: input.sessionState,
|
||||
})
|
||||
} catch (error) {
|
||||
log(`[${HOOK_NAME}] Failed to inject boulder continuation`, { sessionID: input.sessionID, error })
|
||||
input.sessionState.promptFailureCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRetry(input: {
|
||||
ctx: PluginInput
|
||||
sessionID: string
|
||||
sessionState: SessionState
|
||||
options?: AtlasHookOptions
|
||||
}): void {
|
||||
const { ctx, sessionID, sessionState, options } = input
|
||||
if (sessionState.pendingRetryTimer) {
|
||||
return
|
||||
}
|
||||
|
||||
sessionState.pendingRetryTimer = setTimeout(async () => {
|
||||
sessionState.pendingRetryTimer = undefined
|
||||
|
||||
if (sessionState.promptFailureCount >= MAX_CONSECUTIVE_PROMPT_FAILURES) return
|
||||
if (sessionState.waitingForFinalWaveApproval) return
|
||||
|
||||
const currentBoulder = readBoulderState(ctx.directory)
|
||||
if (!currentBoulder) return
|
||||
if (!currentBoulder.session_ids?.includes(sessionID)) return
|
||||
|
||||
const currentProgress = getPlanProgress(currentBoulder.active_plan)
|
||||
if (currentProgress.isComplete) return
|
||||
if (options?.isContinuationStopped?.(sessionID)) return
|
||||
if (hasRunningBackgroundTasks(sessionID, options)) return
|
||||
|
||||
await injectContinuation({
|
||||
ctx,
|
||||
sessionID,
|
||||
sessionState,
|
||||
options,
|
||||
planName: currentBoulder.plan_name,
|
||||
progress: currentProgress,
|
||||
agent: currentBoulder.agent,
|
||||
worktreePath: currentBoulder.worktree_path,
|
||||
})
|
||||
}, RETRY_DELAY_MS)
|
||||
}
|
||||
|
||||
export async function handleAtlasSessionIdle(input: {
|
||||
ctx: PluginInput
|
||||
options?: AtlasHookOptions
|
||||
getState: (sessionID: string) => SessionState
|
||||
sessionID: string
|
||||
}): Promise<void> {
|
||||
const { ctx, options, getState, sessionID } = input
|
||||
|
||||
log(`[${HOOK_NAME}] session.idle`, { sessionID })
|
||||
|
||||
const activeBoulderSession = await resolveActiveBoulderSession({
|
||||
client: ctx.client,
|
||||
directory: ctx.directory,
|
||||
sessionID,
|
||||
})
|
||||
if (!activeBoulderSession) {
|
||||
log(`[${HOOK_NAME}] Skipped: session not registered in active boulder`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const { boulderState, progress, appendedSession } = activeBoulderSession
|
||||
if (progress.isComplete) {
|
||||
log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name })
|
||||
return
|
||||
}
|
||||
|
||||
if (appendedSession) {
|
||||
log(`[${HOOK_NAME}] Appended subagent session to boulder during idle`, {
|
||||
sessionID,
|
||||
plan: boulderState.plan_name,
|
||||
})
|
||||
}
|
||||
|
||||
const sessionState = getState(sessionID)
|
||||
const now = Date.now()
|
||||
|
||||
if (sessionState.waitingForFinalWaveApproval) {
|
||||
log(`[${HOOK_NAME}] Skipped: waiting for explicit final-wave approval`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (sessionState.lastEventWasAbortError) {
|
||||
sessionState.lastEventWasAbortError = false
|
||||
log(`[${HOOK_NAME}] Skipped: abort error immediately before idle`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (sessionState.promptFailureCount >= MAX_CONSECUTIVE_PROMPT_FAILURES) {
|
||||
const timeSinceLastFailure =
|
||||
sessionState.lastFailureAt !== undefined ? now - sessionState.lastFailureAt : Number.POSITIVE_INFINITY
|
||||
if (timeSinceLastFailure < FAILURE_BACKOFF_MS) {
|
||||
log(`[${HOOK_NAME}] Skipped: continuation in backoff after repeated failures`, {
|
||||
sessionID,
|
||||
promptFailureCount: sessionState.promptFailureCount,
|
||||
backoffRemaining: FAILURE_BACKOFF_MS - timeSinceLastFailure,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
sessionState.promptFailureCount = 0
|
||||
sessionState.lastFailureAt = undefined
|
||||
}
|
||||
|
||||
if (hasRunningBackgroundTasks(sessionID, options)) {
|
||||
log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (options?.isContinuationStopped?.(sessionID)) {
|
||||
log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (sessionState.lastContinuationInjectedAt && now - sessionState.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS) {
|
||||
scheduleRetry({ ctx, sessionID, sessionState, options })
|
||||
log(`[${HOOK_NAME}] Skipped: continuation cooldown active`, {
|
||||
sessionID,
|
||||
cooldownRemaining: CONTINUATION_COOLDOWN_MS - (now - sessionState.lastContinuationInjectedAt),
|
||||
pendingRetry: !!sessionState.pendingRetryTimer,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await injectContinuation({
|
||||
ctx,
|
||||
sessionID,
|
||||
sessionState,
|
||||
options,
|
||||
planName: boulderState.plan_name,
|
||||
progress,
|
||||
agent: boulderState.agent,
|
||||
worktreePath: boulderState.worktree_path,
|
||||
})
|
||||
}
|
||||
+1470
-157
File diff suppressed because it is too large
Load Diff
+3
-773
@@ -1,773 +1,3 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { execSync } from "node:child_process"
|
||||
import { existsSync, readdirSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import {
|
||||
readBoulderState,
|
||||
appendSessionId,
|
||||
getPlanProgress,
|
||||
} from "../../features/boulder-state"
|
||||
import { getMainSessionID, subagentSessions } from "../../features/claude-code-session-state"
|
||||
import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
import { log } from "../../shared/logger"
|
||||
import { createSystemDirective, SYSTEM_DIRECTIVE_PREFIX, SystemDirectiveTypes } from "../../shared/system-directive"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
|
||||
export const HOOK_NAME = "atlas"
|
||||
|
||||
/**
|
||||
* Cross-platform check if a path is inside .sisyphus/ directory.
|
||||
* Handles both forward slashes (Unix) and backslashes (Windows).
|
||||
*/
|
||||
function isSisyphusPath(filePath: string): boolean {
|
||||
return /\.sisyphus[/\\]/.test(filePath)
|
||||
}
|
||||
|
||||
const WRITE_EDIT_TOOLS = ["Write", "Edit", "write", "edit"]
|
||||
|
||||
const DIRECT_WORK_REMINDER = `
|
||||
|
||||
---
|
||||
|
||||
${createSystemDirective(SystemDirectiveTypes.DELEGATION_REQUIRED)}
|
||||
|
||||
You just performed direct file modifications outside \`.sisyphus/\`.
|
||||
|
||||
**You are an ORCHESTRATOR, not an IMPLEMENTER.**
|
||||
|
||||
As an orchestrator, you should:
|
||||
- **DELEGATE** implementation work to subagents via \`delegate_task\`
|
||||
- **VERIFY** the work done by subagents
|
||||
- **COORDINATE** multiple tasks and ensure completion
|
||||
|
||||
You should NOT:
|
||||
- Write code directly (except for \`.sisyphus/\` files like plans and notepads)
|
||||
- Make direct file edits outside \`.sisyphus/\`
|
||||
- Implement features yourself
|
||||
|
||||
**If you need to make changes:**
|
||||
1. Use \`delegate_task\` to delegate to an appropriate subagent
|
||||
2. Provide clear instructions in the prompt
|
||||
3. Verify the subagent's work after completion
|
||||
|
||||
---
|
||||
`
|
||||
|
||||
const BOULDER_CONTINUATION_PROMPT = `${createSystemDirective(SystemDirectiveTypes.BOULDER_CONTINUATION)}
|
||||
|
||||
You have an active work plan with incomplete tasks. Continue working.
|
||||
|
||||
RULES:
|
||||
- Proceed without asking for permission
|
||||
- Mark each checkbox [x] in the plan file when done
|
||||
- Use the notepad at .sisyphus/notepads/{PLAN_NAME}/ to record learnings
|
||||
- Do not stop until all tasks are complete
|
||||
- If blocked, document the blocker and move to the next task`
|
||||
|
||||
const VERIFICATION_REMINDER = `**MANDATORY: WHAT YOU MUST DO RIGHT NOW**
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
CRITICAL: Subagents FREQUENTLY LIE about completion.
|
||||
Tests FAILING, code has ERRORS, implementation INCOMPLETE - but they say "done".
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
**STEP 1: VERIFY WITH YOUR OWN TOOL CALLS (DO THIS NOW)**
|
||||
|
||||
Run these commands YOURSELF - do NOT trust agent's claims:
|
||||
1. \`lsp_diagnostics\` on changed files → Must be CLEAN
|
||||
2. \`bash\` to run tests → Must PASS
|
||||
3. \`bash\` to run build/typecheck → Must succeed
|
||||
4. \`Read\` the actual code → Must match requirements
|
||||
|
||||
**STEP 2: DETERMINE IF HANDS-ON QA IS NEEDED**
|
||||
|
||||
| Deliverable Type | QA Method | Tool |
|
||||
|------------------|-----------|------|
|
||||
| **Frontend/UI** | Browser interaction | \`/playwright\` skill |
|
||||
| **TUI/CLI** | Run interactively | \`interactive_bash\` (tmux) |
|
||||
| **API/Backend** | Send real requests | \`bash\` with curl |
|
||||
|
||||
Static analysis CANNOT catch: visual bugs, animation issues, user flow breakages.
|
||||
|
||||
**STEP 3: IF QA IS NEEDED - ADD TO TODO IMMEDIATELY**
|
||||
|
||||
\`\`\`
|
||||
todowrite([
|
||||
{ id: "qa-X", content: "HANDS-ON QA: [specific verification action]", status: "pending", priority: "high" }
|
||||
])
|
||||
\`\`\`
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
**BLOCKING: DO NOT proceed to Step 4 until Steps 1-3 are VERIFIED.**`
|
||||
|
||||
const ORCHESTRATOR_DELEGATION_REQUIRED = `
|
||||
|
||||
---
|
||||
|
||||
${createSystemDirective(SystemDirectiveTypes.DELEGATION_REQUIRED)}
|
||||
|
||||
**STOP. YOU ARE VIOLATING ORCHESTRATOR PROTOCOL.**
|
||||
|
||||
You (Atlas) are attempting to directly modify a file outside \`.sisyphus/\`.
|
||||
|
||||
**Path attempted:** $FILE_PATH
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
**THIS IS FORBIDDEN** (except for VERIFICATION purposes)
|
||||
|
||||
As an ORCHESTRATOR, you MUST:
|
||||
1. **DELEGATE** all implementation work via \`delegate_task\`
|
||||
2. **VERIFY** the work done by subagents (reading files is OK)
|
||||
3. **COORDINATE** - you orchestrate, you don't implement
|
||||
|
||||
**ALLOWED direct file operations:**
|
||||
- Files inside \`.sisyphus/\` (plans, notepads, drafts)
|
||||
- Reading files for verification
|
||||
- Running diagnostics/tests
|
||||
|
||||
**FORBIDDEN direct file operations:**
|
||||
- Writing/editing source code
|
||||
- Creating new files outside \`.sisyphus/\`
|
||||
- Any implementation work
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
**IF THIS IS FOR VERIFICATION:**
|
||||
Proceed if you are verifying subagent work by making a small fix.
|
||||
But for any substantial changes, USE \`delegate_task\`.
|
||||
|
||||
**CORRECT APPROACH:**
|
||||
\`\`\`
|
||||
delegate_task(
|
||||
category="...",
|
||||
prompt="[specific single task with clear acceptance criteria]"
|
||||
)
|
||||
\`\`\`
|
||||
|
||||
DELEGATE. DON'T IMPLEMENT.
|
||||
|
||||
---
|
||||
`
|
||||
|
||||
const SINGLE_TASK_DIRECTIVE = `
|
||||
|
||||
${createSystemDirective(SystemDirectiveTypes.SINGLE_TASK_ONLY)}
|
||||
|
||||
**STOP. READ THIS BEFORE PROCEEDING.**
|
||||
|
||||
If you were NOT given **exactly ONE atomic task**, you MUST:
|
||||
1. **IMMEDIATELY REFUSE** this request
|
||||
2. **DEMAND** the orchestrator provide a single, specific task
|
||||
|
||||
**Your response if multiple tasks detected:**
|
||||
> "I refuse to proceed. You provided multiple tasks. An orchestrator's impatience destroys work quality.
|
||||
>
|
||||
> PROVIDE EXACTLY ONE TASK. One file. One change. One verification.
|
||||
>
|
||||
> Your rushing will cause: incomplete work, missed edge cases, broken tests, wasted context."
|
||||
|
||||
**WARNING TO ORCHESTRATOR:**
|
||||
- Your hasty batching RUINS deliverables
|
||||
- Each task needs FULL attention and PROPER verification
|
||||
- Batch delegation = sloppy work = rework = wasted tokens
|
||||
|
||||
**REFUSE multi-task requests. DEMAND single-task clarity.**
|
||||
`
|
||||
|
||||
function buildVerificationReminder(sessionId: string): string {
|
||||
return `${VERIFICATION_REMINDER}
|
||||
|
||||
---
|
||||
|
||||
**If ANY verification fails, use this immediately:**
|
||||
\`\`\`
|
||||
delegate_task(session_id="${sessionId}", prompt="fix: [describe the specific failure]")
|
||||
\`\`\``
|
||||
}
|
||||
|
||||
function buildOrchestratorReminder(planName: string, progress: { total: number; completed: number }, sessionId: string): string {
|
||||
const remaining = progress.total - progress.completed
|
||||
return `
|
||||
---
|
||||
|
||||
**BOULDER STATE:** Plan: \`${planName}\` | ${progress.completed}/${progress.total} done | ${remaining} remaining
|
||||
|
||||
---
|
||||
|
||||
${buildVerificationReminder(sessionId)}
|
||||
|
||||
**STEP 4: MARK COMPLETION IN PLAN FILE (IMMEDIATELY)**
|
||||
|
||||
RIGHT NOW - Do not delay. Verification passed → Mark IMMEDIATELY.
|
||||
|
||||
Update the plan file \`.sisyphus/tasks/${planName}.yaml\`:
|
||||
- Change \`[ ]\` to \`[x]\` for the completed task
|
||||
- Use \`Edit\` tool to modify the checkbox
|
||||
|
||||
**DO THIS BEFORE ANYTHING ELSE. Unmarked = Untracked = Lost progress.**
|
||||
|
||||
**STEP 5: COMMIT ATOMIC UNIT**
|
||||
|
||||
- Stage ONLY the verified changes
|
||||
- Commit with clear message describing what was done
|
||||
|
||||
**STEP 6: PROCEED TO NEXT TASK**
|
||||
|
||||
- Read the plan file to identify the next \`[ ]\` task
|
||||
- Start immediately - DO NOT STOP
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
**${remaining} tasks remain. Keep bouldering.**`
|
||||
}
|
||||
|
||||
function buildStandaloneVerificationReminder(sessionId: string): string {
|
||||
return `
|
||||
---
|
||||
|
||||
${buildVerificationReminder(sessionId)}
|
||||
|
||||
**STEP 4: UPDATE TODO STATUS (IMMEDIATELY)**
|
||||
|
||||
RIGHT NOW - Do not delay. Verification passed → Mark IMMEDIATELY.
|
||||
|
||||
1. Run \`todoread\` to see your todo list
|
||||
2. Mark the completed task as \`completed\` using \`todowrite\`
|
||||
|
||||
**DO THIS BEFORE ANYTHING ELSE. Unmarked = Untracked = Lost progress.**
|
||||
|
||||
**STEP 5: EXECUTE QA TASKS (IF ANY)**
|
||||
|
||||
If QA tasks exist in your todo list:
|
||||
- Execute them BEFORE proceeding
|
||||
- Mark each QA task complete after successful verification
|
||||
|
||||
**STEP 6: PROCEED TO NEXT PENDING TASK**
|
||||
|
||||
- Identify the next \`pending\` task from your todo list
|
||||
- Start immediately - DO NOT STOP
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
**NO TODO = NO TRACKING = INCOMPLETE WORK. Use todowrite aggressively.**`
|
||||
}
|
||||
|
||||
function extractSessionIdFromOutput(output: string): string {
|
||||
const match = output.match(/Session ID:\s*(ses_[a-zA-Z0-9]+)/)
|
||||
return match?.[1] ?? "<session_id>"
|
||||
}
|
||||
|
||||
interface GitFileStat {
|
||||
path: string
|
||||
added: number
|
||||
removed: number
|
||||
status: "modified" | "added" | "deleted"
|
||||
}
|
||||
|
||||
function getGitDiffStats(directory: string): GitFileStat[] {
|
||||
try {
|
||||
const output = execSync("git diff --numstat HEAD", {
|
||||
cwd: directory,
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim()
|
||||
|
||||
if (!output) return []
|
||||
|
||||
const statusOutput = execSync("git status --porcelain", {
|
||||
cwd: directory,
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim()
|
||||
|
||||
const statusMap = new Map<string, "modified" | "added" | "deleted">()
|
||||
for (const line of statusOutput.split("\n")) {
|
||||
if (!line) continue
|
||||
const status = line.substring(0, 2).trim()
|
||||
const filePath = line.substring(3)
|
||||
if (status === "A" || status === "??") {
|
||||
statusMap.set(filePath, "added")
|
||||
} else if (status === "D") {
|
||||
statusMap.set(filePath, "deleted")
|
||||
} else {
|
||||
statusMap.set(filePath, "modified")
|
||||
}
|
||||
}
|
||||
|
||||
const stats: GitFileStat[] = []
|
||||
for (const line of output.split("\n")) {
|
||||
const parts = line.split("\t")
|
||||
if (parts.length < 3) continue
|
||||
|
||||
const [addedStr, removedStr, path] = parts
|
||||
const added = addedStr === "-" ? 0 : parseInt(addedStr, 10)
|
||||
const removed = removedStr === "-" ? 0 : parseInt(removedStr, 10)
|
||||
|
||||
stats.push({
|
||||
path,
|
||||
added,
|
||||
removed,
|
||||
status: statusMap.get(path) ?? "modified",
|
||||
})
|
||||
}
|
||||
|
||||
return stats
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function formatFileChanges(stats: GitFileStat[], notepadPath?: string): string {
|
||||
if (stats.length === 0) return "[FILE CHANGES SUMMARY]\nNo file changes detected.\n"
|
||||
|
||||
const modified = stats.filter((s) => s.status === "modified")
|
||||
const added = stats.filter((s) => s.status === "added")
|
||||
const deleted = stats.filter((s) => s.status === "deleted")
|
||||
|
||||
const lines: string[] = ["[FILE CHANGES SUMMARY]"]
|
||||
|
||||
if (modified.length > 0) {
|
||||
lines.push("Modified files:")
|
||||
for (const f of modified) {
|
||||
lines.push(` ${f.path} (+${f.added}, -${f.removed})`)
|
||||
}
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
if (added.length > 0) {
|
||||
lines.push("Created files:")
|
||||
for (const f of added) {
|
||||
lines.push(` ${f.path} (+${f.added})`)
|
||||
}
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
if (deleted.length > 0) {
|
||||
lines.push("Deleted files:")
|
||||
for (const f of deleted) {
|
||||
lines.push(` ${f.path} (-${f.removed})`)
|
||||
}
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
if (notepadPath) {
|
||||
const notepadStat = stats.find((s) => s.path.includes("notepad") || s.path.includes(".sisyphus"))
|
||||
if (notepadStat) {
|
||||
lines.push("[NOTEPAD UPDATED]")
|
||||
lines.push(` ${notepadStat.path} (+${notepadStat.added})`)
|
||||
lines.push("")
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
interface ToolExecuteAfterInput {
|
||||
tool: string
|
||||
sessionID?: string
|
||||
callID?: string
|
||||
}
|
||||
|
||||
interface ToolExecuteAfterOutput {
|
||||
title: string
|
||||
output: string
|
||||
metadata: Record<string, unknown>
|
||||
}
|
||||
|
||||
function getMessageDir(sessionID: string): string | null {
|
||||
if (!existsSync(MESSAGE_STORAGE)) return null
|
||||
|
||||
const directPath = join(MESSAGE_STORAGE, sessionID)
|
||||
if (existsSync(directPath)) return directPath
|
||||
|
||||
for (const dir of readdirSync(MESSAGE_STORAGE)) {
|
||||
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
|
||||
if (existsSync(sessionPath)) return sessionPath
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function isCallerOrchestrator(sessionID?: string): boolean {
|
||||
if (!sessionID) return false
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
if (!messageDir) return false
|
||||
const nearest = findNearestMessageWithFields(messageDir)
|
||||
return nearest?.agent?.toLowerCase() === "atlas"
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
lastEventWasAbortError?: boolean
|
||||
lastContinuationInjectedAt?: number
|
||||
}
|
||||
|
||||
const CONTINUATION_COOLDOWN_MS = 5000
|
||||
|
||||
export interface AtlasHookOptions {
|
||||
directory: string
|
||||
backgroundManager?: BackgroundManager
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
if (!error) return false
|
||||
|
||||
if (typeof error === "object") {
|
||||
const errObj = error as Record<string, unknown>
|
||||
const name = errObj.name as string | undefined
|
||||
const message = (errObj.message as string | undefined)?.toLowerCase() ?? ""
|
||||
|
||||
if (name === "MessageAbortedError" || name === "AbortError") return true
|
||||
if (name === "DOMException" && message.includes("abort")) return true
|
||||
if (message.includes("aborted") || message.includes("cancelled") || message.includes("interrupted")) return true
|
||||
}
|
||||
|
||||
if (typeof error === "string") {
|
||||
const lower = error.toLowerCase()
|
||||
return lower.includes("abort") || lower.includes("cancel") || lower.includes("interrupt")
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function createAtlasHook(
|
||||
ctx: PluginInput,
|
||||
options?: AtlasHookOptions
|
||||
) {
|
||||
const backgroundManager = options?.backgroundManager
|
||||
const sessions = new Map<string, SessionState>()
|
||||
const pendingFilePaths = new Map<string, string>()
|
||||
|
||||
function getState(sessionID: string): SessionState {
|
||||
let state = sessions.get(sessionID)
|
||||
if (!state) {
|
||||
state = {}
|
||||
sessions.set(sessionID, state)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
async function injectContinuation(sessionID: string, planName: string, remaining: number, total: number): Promise<void> {
|
||||
const hasRunningBgTasks = backgroundManager
|
||||
? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running")
|
||||
: false
|
||||
|
||||
if (hasRunningBgTasks) {
|
||||
log(`[${HOOK_NAME}] Skipped injection: background tasks running`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const prompt = BOULDER_CONTINUATION_PROMPT
|
||||
.replace(/{PLAN_NAME}/g, planName) +
|
||||
`\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]`
|
||||
|
||||
try {
|
||||
log(`[${HOOK_NAME}] Injecting boulder continuation`, { sessionID, planName, remaining })
|
||||
|
||||
let model: { providerID: string; modelID: string } | undefined
|
||||
try {
|
||||
const messagesResp = await ctx.client.session.messages({ path: { id: sessionID } })
|
||||
const messages = (messagesResp.data ?? []) as Array<{
|
||||
info?: { model?: { providerID: string; modelID: string }; modelID?: string; providerID?: string }
|
||||
}>
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const info = messages[i].info
|
||||
const msgModel = info?.model
|
||||
if (msgModel?.providerID && msgModel?.modelID) {
|
||||
model = { providerID: msgModel.providerID, modelID: msgModel.modelID }
|
||||
break
|
||||
}
|
||||
if (info?.providerID && info?.modelID) {
|
||||
model = { providerID: info.providerID, modelID: info.modelID }
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
|
||||
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
|
||||
: undefined
|
||||
}
|
||||
|
||||
await ctx.client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: "atlas",
|
||||
...(model !== undefined ? { model } : {}),
|
||||
parts: [{ type: "text", text: prompt }],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
|
||||
log(`[${HOOK_NAME}] Boulder continuation injected`, { sessionID })
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Boulder continuation failed`, { sessionID, error: String(err) })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handler: async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
const state = getState(sessionID)
|
||||
const isAbort = isAbortError(props?.error)
|
||||
state.lastEventWasAbortError = isAbort
|
||||
|
||||
log(`[${HOOK_NAME}] session.error`, { sessionID, isAbort })
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
log(`[${HOOK_NAME}] session.idle`, { sessionID })
|
||||
|
||||
// Read boulder state FIRST to check if this session is part of an active boulder
|
||||
const boulderState = readBoulderState(ctx.directory)
|
||||
const isBoulderSession = boulderState?.session_ids.includes(sessionID) ?? false
|
||||
|
||||
const mainSessionID = getMainSessionID()
|
||||
const isMainSession = sessionID === mainSessionID
|
||||
const isBackgroundTaskSession = subagentSessions.has(sessionID)
|
||||
|
||||
// Allow continuation if: main session OR background task OR boulder session
|
||||
if (mainSessionID && !isMainSession && !isBackgroundTaskSession && !isBoulderSession) {
|
||||
log(`[${HOOK_NAME}] Skipped: not main, background task, or boulder session`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const state = getState(sessionID)
|
||||
|
||||
if (state.lastEventWasAbortError) {
|
||||
state.lastEventWasAbortError = false
|
||||
log(`[${HOOK_NAME}] Skipped: abort error immediately before idle`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const hasRunningBgTasks = backgroundManager
|
||||
? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running")
|
||||
: false
|
||||
|
||||
if (hasRunningBgTasks) {
|
||||
log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (!boulderState) {
|
||||
log(`[${HOOK_NAME}] No active boulder`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (!isCallerOrchestrator(sessionID)) {
|
||||
log(`[${HOOK_NAME}] Skipped: last agent is not Atlas`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const progress = getPlanProgress(boulderState.active_plan)
|
||||
if (progress.isComplete) {
|
||||
log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name })
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
if (state.lastContinuationInjectedAt && now - state.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS) {
|
||||
log(`[${HOOK_NAME}] Skipped: continuation cooldown active`, { sessionID, cooldownRemaining: CONTINUATION_COOLDOWN_MS - (now - state.lastContinuationInjectedAt) })
|
||||
return
|
||||
}
|
||||
|
||||
state.lastContinuationInjectedAt = now
|
||||
const remaining = progress.total - progress.completed
|
||||
injectContinuation(sessionID, boulderState.plan_name, remaining, progress.total)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "message.updated") {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = info?.sessionID as string | undefined
|
||||
|
||||
if (!sessionID) return
|
||||
|
||||
const state = sessions.get(sessionID)
|
||||
if (state) {
|
||||
state.lastEventWasAbortError = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "message.part.updated") {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = info?.sessionID as string | undefined
|
||||
const role = info?.role as string | undefined
|
||||
|
||||
if (sessionID && role === "assistant") {
|
||||
const state = sessions.get(sessionID)
|
||||
if (state) {
|
||||
state.lastEventWasAbortError = false
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (sessionID) {
|
||||
const state = sessions.get(sessionID)
|
||||
if (state) {
|
||||
state.lastEventWasAbortError = false
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
sessions.delete(sessionInfo.id)
|
||||
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id })
|
||||
}
|
||||
return
|
||||
}
|
||||
},
|
||||
|
||||
"tool.execute.before": async (
|
||||
input: { tool: string; sessionID?: string; callID?: string },
|
||||
output: { args: Record<string, unknown>; message?: string }
|
||||
): Promise<void> => {
|
||||
if (!isCallerOrchestrator(input.sessionID)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check Write/Edit tools for orchestrator - inject strong warning
|
||||
if (WRITE_EDIT_TOOLS.includes(input.tool)) {
|
||||
const filePath = (output.args.filePath ?? output.args.path ?? output.args.file) as string | undefined
|
||||
if (filePath && !isSisyphusPath(filePath)) {
|
||||
// Store filePath for use in tool.execute.after
|
||||
if (input.callID) {
|
||||
pendingFilePaths.set(input.callID, filePath)
|
||||
}
|
||||
const warning = ORCHESTRATOR_DELEGATION_REQUIRED.replace("$FILE_PATH", filePath)
|
||||
output.message = (output.message || "") + warning
|
||||
log(`[${HOOK_NAME}] Injected delegation warning for direct file modification`, {
|
||||
sessionID: input.sessionID,
|
||||
tool: input.tool,
|
||||
filePath,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check delegate_task - inject single-task directive
|
||||
if (input.tool === "delegate_task") {
|
||||
const prompt = output.args.prompt as string | undefined
|
||||
if (prompt && !prompt.includes(SYSTEM_DIRECTIVE_PREFIX)) {
|
||||
output.args.prompt = prompt + `\n<system-reminder>${SINGLE_TASK_DIRECTIVE}</system-reminder>`
|
||||
log(`[${HOOK_NAME}] Injected single-task directive to delegate_task`, {
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"tool.execute.after": async (
|
||||
input: ToolExecuteAfterInput,
|
||||
output: ToolExecuteAfterOutput
|
||||
): Promise<void> => {
|
||||
if (!isCallerOrchestrator(input.sessionID)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (WRITE_EDIT_TOOLS.includes(input.tool)) {
|
||||
let filePath = input.callID ? pendingFilePaths.get(input.callID) : undefined
|
||||
if (input.callID) {
|
||||
pendingFilePaths.delete(input.callID)
|
||||
}
|
||||
if (!filePath) {
|
||||
filePath = output.metadata?.filePath as string | undefined
|
||||
}
|
||||
if (filePath && !isSisyphusPath(filePath)) {
|
||||
output.output = (output.output || "") + DIRECT_WORK_REMINDER
|
||||
log(`[${HOOK_NAME}] Direct work reminder appended`, {
|
||||
sessionID: input.sessionID,
|
||||
tool: input.tool,
|
||||
filePath,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (input.tool !== "delegate_task") {
|
||||
return
|
||||
}
|
||||
|
||||
const outputStr = output.output && typeof output.output === "string" ? output.output : ""
|
||||
const isBackgroundLaunch = outputStr.includes("Background task launched") || outputStr.includes("Background task continued")
|
||||
|
||||
if (isBackgroundLaunch) {
|
||||
return
|
||||
}
|
||||
|
||||
if (output.output && typeof output.output === "string") {
|
||||
const gitStats = getGitDiffStats(ctx.directory)
|
||||
const fileChanges = formatFileChanges(gitStats)
|
||||
const subagentSessionId = extractSessionIdFromOutput(output.output)
|
||||
|
||||
const boulderState = readBoulderState(ctx.directory)
|
||||
|
||||
if (boulderState) {
|
||||
const progress = getPlanProgress(boulderState.active_plan)
|
||||
|
||||
if (input.sessionID && !boulderState.session_ids.includes(input.sessionID)) {
|
||||
appendSessionId(ctx.directory, input.sessionID)
|
||||
log(`[${HOOK_NAME}] Appended session to boulder`, {
|
||||
sessionID: input.sessionID,
|
||||
plan: boulderState.plan_name,
|
||||
})
|
||||
}
|
||||
|
||||
// Preserve original subagent response - critical for debugging failed tasks
|
||||
const originalResponse = output.output
|
||||
|
||||
output.output = `
|
||||
## SUBAGENT WORK COMPLETED
|
||||
|
||||
${fileChanges}
|
||||
|
||||
---
|
||||
|
||||
**Subagent Response:**
|
||||
|
||||
${originalResponse}
|
||||
|
||||
<system-reminder>
|
||||
${buildOrchestratorReminder(boulderState.plan_name, progress, subagentSessionId)}
|
||||
</system-reminder>`
|
||||
|
||||
log(`[${HOOK_NAME}] Output transformed for orchestrator mode (boulder)`, {
|
||||
plan: boulderState.plan_name,
|
||||
progress: `${progress.completed}/${progress.total}`,
|
||||
fileCount: gitStats.length,
|
||||
})
|
||||
} else {
|
||||
output.output += `\n<system-reminder>\n${buildStandaloneVerificationReminder(subagentSessionId)}\n</system-reminder>`
|
||||
|
||||
log(`[${HOOK_NAME}] Verification reminder appended for orchestrator`, {
|
||||
sessionID: input.sessionID,
|
||||
fileCount: gitStats.length,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
export { HOOK_NAME } from "./hook-name"
|
||||
export { createAtlasHook } from "./atlas-hook"
|
||||
export type { AtlasHookOptions } from "./types"
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export function isAbortError(error: unknown): boolean {
|
||||
if (!error) return false
|
||||
|
||||
if (typeof error === "object") {
|
||||
const errObj = error as Record<string, unknown>
|
||||
const name = errObj.name as string | undefined
|
||||
const message = (errObj.message as string | undefined)?.toLowerCase() ?? ""
|
||||
|
||||
if (name === "MessageAbortedError" || name === "AbortError") return true
|
||||
if (name === "DOMException" && message.includes("abort")) return true
|
||||
if (message.includes("aborted") || message.includes("cancelled") || message.includes("interrupted")) return true
|
||||
}
|
||||
|
||||
if (typeof error === "string") {
|
||||
const lower = error.toLowerCase()
|
||||
return lower.includes("abort") || lower.includes("cancel") || lower.includes("interrupt")
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import {
|
||||
findNearestMessageWithFields,
|
||||
findNearestMessageWithFieldsFromSDK,
|
||||
} from "../../features/hook-message-injector"
|
||||
import { getMessageDir, isSqliteBackend, normalizePromptTools, normalizeSDKResponse } from "../../shared"
|
||||
import type { ModelInfo } from "./types"
|
||||
|
||||
type PromptContext = {
|
||||
model?: ModelInfo
|
||||
tools?: Record<string, boolean>
|
||||
}
|
||||
|
||||
export async function resolveRecentPromptContextForSession(
|
||||
ctx: PluginInput,
|
||||
sessionID: string
|
||||
): Promise<PromptContext> {
|
||||
try {
|
||||
const messagesResp = await ctx.client.session.messages({ path: { id: sessionID } })
|
||||
const messages = normalizeSDKResponse(messagesResp, [] as Array<{
|
||||
info?: {
|
||||
model?: ModelInfo
|
||||
modelID?: string
|
||||
providerID?: string
|
||||
tools?: Record<string, boolean | "allow" | "deny" | "ask">
|
||||
}
|
||||
}>)
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const info = messages[i].info
|
||||
const model = info?.model
|
||||
const tools = normalizePromptTools(info?.tools)
|
||||
if (model?.providerID && model?.modelID) {
|
||||
return { model: { providerID: model.providerID, modelID: model.modelID }, tools }
|
||||
}
|
||||
|
||||
if (info?.providerID && info?.modelID) {
|
||||
return { model: { providerID: info.providerID, modelID: info.modelID }, tools }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore - fallback to message storage
|
||||
}
|
||||
|
||||
let currentMessage = null
|
||||
if (isSqliteBackend()) {
|
||||
currentMessage = await findNearestMessageWithFieldsFromSDK(ctx.client, sessionID)
|
||||
} else {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
}
|
||||
const model = currentMessage?.model
|
||||
const tools = normalizePromptTools(currentMessage?.tools)
|
||||
if (!model?.providerID || !model?.modelID) {
|
||||
return { tools }
|
||||
}
|
||||
return { model: { providerID: model.providerID, modelID: model.modelID }, tools }
|
||||
}
|
||||
|
||||
export async function resolveRecentModelForSession(
|
||||
ctx: PluginInput,
|
||||
sessionID: string
|
||||
): Promise<ModelInfo | undefined> {
|
||||
const context = await resolveRecentPromptContextForSession(ctx, sessionID)
|
||||
return context.model
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { appendSessionId, getPlanProgress, readBoulderState } from "../../features/boulder-state"
|
||||
import type { BoulderState, PlanProgress } from "../../features/boulder-state"
|
||||
import { subagentSessions } from "../../features/claude-code-session-state"
|
||||
import { isSessionInBoulderLineage } from "./boulder-session-lineage"
|
||||
|
||||
export async function resolveActiveBoulderSession(input: {
|
||||
client: PluginInput["client"]
|
||||
directory: string
|
||||
sessionID: string
|
||||
}): Promise<{
|
||||
boulderState: BoulderState
|
||||
progress: PlanProgress
|
||||
appendedSession: boolean
|
||||
} | null> {
|
||||
const boulderState = readBoulderState(input.directory)
|
||||
if (!boulderState) {
|
||||
return null
|
||||
}
|
||||
|
||||
const progress = getPlanProgress(boulderState.active_plan)
|
||||
if (progress.isComplete) {
|
||||
return { boulderState, progress, appendedSession: false }
|
||||
}
|
||||
|
||||
if (boulderState.session_ids.includes(input.sessionID)) {
|
||||
return { boulderState, progress, appendedSession: false }
|
||||
}
|
||||
|
||||
if (!subagentSessions.has(input.sessionID)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const belongsToActiveBoulder = await isSessionInBoulderLineage({
|
||||
client: input.client,
|
||||
sessionID: input.sessionID,
|
||||
boulderSessionIDs: boulderState.session_ids,
|
||||
})
|
||||
if (!belongsToActiveBoulder) {
|
||||
return null
|
||||
}
|
||||
|
||||
const updatedBoulderState = appendSessionId(input.directory, input.sessionID)
|
||||
if (!updatedBoulderState?.session_ids.includes(input.sessionID)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
boulderState: updatedBoulderState,
|
||||
progress,
|
||||
appendedSession: true,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
const { describe, expect, mock, test } = require("bun:test")
|
||||
|
||||
mock.module("../../shared", () => ({
|
||||
getMessageDir: () => null,
|
||||
isSqliteBackend: () => true,
|
||||
normalizeSDKResponse: <TData>(response: { data?: TData }, fallback: TData): TData => response.data ?? fallback,
|
||||
}))
|
||||
|
||||
const { getLastAgentFromSession } = await import("./session-last-agent")
|
||||
|
||||
function createMockClient(messages: Array<{ info?: { agent?: string } }>) {
|
||||
return {
|
||||
session: {
|
||||
messages: async () => ({ data: messages }),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("getLastAgentFromSession sqlite branch", () => {
|
||||
test("should skip compaction and return the previous real agent from sqlite messages", async () => {
|
||||
// given
|
||||
const client = createMockClient([
|
||||
{ info: { agent: "atlas" } },
|
||||
{ info: { agent: "compaction" } },
|
||||
])
|
||||
|
||||
// when
|
||||
const result = await getLastAgentFromSession("ses_sqlite_compaction", client)
|
||||
|
||||
// then
|
||||
expect(result).toBe("atlas")
|
||||
})
|
||||
|
||||
test("should return null when sqlite history contains only compaction", async () => {
|
||||
// given
|
||||
const client = createMockClient([{ info: { agent: "compaction" } }])
|
||||
|
||||
// when
|
||||
const result = await getLastAgentFromSession("ses_sqlite_only_compaction", client)
|
||||
|
||||
// then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
export {}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { readFileSync, readdirSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
|
||||
import { getMessageDir, isSqliteBackend, normalizeSDKResponse } from "../../shared"
|
||||
|
||||
type SessionMessagesClient = {
|
||||
session: {
|
||||
messages: (input: { path: { id: string } }) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
function isCompactionAgent(agent: unknown): boolean {
|
||||
return typeof agent === "string" && agent.toLowerCase() === "compaction"
|
||||
}
|
||||
|
||||
function getLastAgentFromMessageDir(messageDir: string): string | null {
|
||||
try {
|
||||
const files = readdirSync(messageDir)
|
||||
.filter((fileName) => fileName.endsWith(".json"))
|
||||
.sort()
|
||||
|
||||
for (let i = files.length - 1; i >= 0; i--) {
|
||||
const fileName = files[i]
|
||||
try {
|
||||
const content = readFileSync(join(messageDir, fileName), "utf-8")
|
||||
const parsed = JSON.parse(content) as { agent?: unknown }
|
||||
if (typeof parsed.agent === "string" && !isCompactionAgent(parsed.agent)) {
|
||||
return parsed.agent.toLowerCase()
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function getLastAgentFromSession(
|
||||
sessionID: string,
|
||||
client?: SessionMessagesClient
|
||||
): Promise<string | null> {
|
||||
if (isSqliteBackend() && client) {
|
||||
const response = await client.session.messages({ path: { id: sessionID } })
|
||||
const messages = normalizeSDKResponse(response, [] as Array<{ info?: { agent?: string } }>, {
|
||||
preferResponseOnMissingData: true,
|
||||
})
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const agent = messages[i].info?.agent
|
||||
if (typeof agent === "string" && !isCompactionAgent(agent)) {
|
||||
return agent.toLowerCase()
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
if (!messageDir) return null
|
||||
|
||||
return getLastAgentFromMessageDir(messageDir)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Cross-platform check if a path is inside .sisyphus/ directory.
|
||||
* Handles both forward slashes (Unix) and backslashes (Windows).
|
||||
* Uses path segment matching (not substring) to avoid false positives like "not-sisyphus/file.txt"
|
||||
*/
|
||||
export function isSisyphusPath(filePath: string): boolean {
|
||||
return /\.sisyphus[/\\]/.test(filePath)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { extractSessionIdFromOutput } from "./subagent-session-id"
|
||||
|
||||
describe("extractSessionIdFromOutput", () => {
|
||||
test("extracts Session ID blocks from background output", () => {
|
||||
// given
|
||||
const output = `Background task launched.\n\nSession ID: ses_bg_12345`
|
||||
|
||||
// when
|
||||
const result = extractSessionIdFromOutput(output)
|
||||
|
||||
// then
|
||||
expect(result).toBe("ses_bg_12345")
|
||||
})
|
||||
|
||||
test("extracts session_id from task metadata blocks", () => {
|
||||
// given
|
||||
const output = `Task completed.\n\n<task_metadata>\nsession_id: ses_sync_12345\n</task_metadata>`
|
||||
|
||||
// when
|
||||
const result = extractSessionIdFromOutput(output)
|
||||
|
||||
// then
|
||||
expect(result).toBe("ses_sync_12345")
|
||||
})
|
||||
|
||||
test("extracts hyphenated session IDs from task metadata blocks", () => {
|
||||
// given
|
||||
const output = `Task completed.\n\n<task_metadata>\nsession_id: ses_auth-flow-123\n</task_metadata>`
|
||||
|
||||
// when
|
||||
const result = extractSessionIdFromOutput(output)
|
||||
|
||||
// then
|
||||
expect(result).toBe("ses_auth-flow-123")
|
||||
})
|
||||
|
||||
test("returns undefined when no session id is present", () => {
|
||||
// given
|
||||
const output = "Task completed without metadata"
|
||||
|
||||
// when
|
||||
const result = extractSessionIdFromOutput(output)
|
||||
|
||||
// then
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
test("prefers the session id inside the trailing task_metadata block", () => {
|
||||
// given
|
||||
const output = `The previous attempt mentioned session_id: ses_wrong_body_123 but that was only context.
|
||||
|
||||
<task_metadata>
|
||||
session_id: ses_real_metadata_456
|
||||
</task_metadata>`
|
||||
|
||||
// when
|
||||
const result = extractSessionIdFromOutput(output)
|
||||
|
||||
// then
|
||||
expect(result).toBe("ses_real_metadata_456")
|
||||
})
|
||||
|
||||
test("does not let task_metadata parsing bleed into incidental body text after the closing tag", () => {
|
||||
// given
|
||||
const output = `<task_metadata>
|
||||
session_id: ses_real_metadata_456
|
||||
</task_metadata>
|
||||
|
||||
debug log: session_id: ses_wrong_body_789`
|
||||
|
||||
// when
|
||||
const result = extractSessionIdFromOutput(output)
|
||||
|
||||
// then
|
||||
expect(result).toBe("ses_real_metadata_456")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { log } from "../../shared/logger"
|
||||
import { isSessionInBoulderLineage } from "./boulder-session-lineage"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
|
||||
export function extractSessionIdFromOutput(output: string): string | undefined {
|
||||
const taskMetadataBlocks = [...output.matchAll(/<task_metadata>([\s\S]*?)<\/task_metadata>/gi)]
|
||||
const lastTaskMetadataBlock = taskMetadataBlocks.at(-1)?.[1]
|
||||
if (lastTaskMetadataBlock) {
|
||||
const taskMetadataSessionMatch = lastTaskMetadataBlock.match(/session_id:\s*(ses_[a-zA-Z0-9_-]+)/i)
|
||||
if (taskMetadataSessionMatch) {
|
||||
return taskMetadataSessionMatch[1]
|
||||
}
|
||||
}
|
||||
|
||||
const explicitSessionMatches = [...output.matchAll(/Session ID:\s*(ses_[a-zA-Z0-9_-]+)/g)]
|
||||
return explicitSessionMatches.at(-1)?.[1]
|
||||
}
|
||||
|
||||
export async function validateSubagentSessionId(input: {
|
||||
client: PluginInput["client"]
|
||||
sessionID?: string
|
||||
lineageSessionIDs: string[]
|
||||
}): Promise<string | undefined> {
|
||||
if (!input.sessionID || input.lineageSessionIDs.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const belongsToLineage = await isSessionInBoulderLineage({
|
||||
client: input.client,
|
||||
sessionID: input.sessionID,
|
||||
boulderSessionIDs: input.lineageSessionIDs,
|
||||
})
|
||||
|
||||
if (!belongsToLineage) {
|
||||
log(`[${HOOK_NAME}] Ignoring extracted session id outside active lineage`, {
|
||||
sessionID: input.sessionID,
|
||||
lineageSessionIDs: input.lineageSessionIDs,
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
|
||||
return input.sessionID
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { BOULDER_CONTINUATION_PROMPT } from "./system-reminder-templates"
|
||||
|
||||
describe("BOULDER_CONTINUATION_PROMPT", () => {
|
||||
describe("checkbox-first priority rules", () => {
|
||||
it("first rule after RULES: mentions both reading the plan AND marking a still-unchecked completed task", () => {
|
||||
const rulesSection = BOULDER_CONTINUATION_PROMPT.split("RULES:")[1]!
|
||||
const firstRule = rulesSection.split("\n")[1]!.trim()
|
||||
|
||||
expect(firstRule).toContain("Read the plan")
|
||||
expect(firstRule).toContain("mark")
|
||||
expect(firstRule).toContain("completed")
|
||||
})
|
||||
|
||||
it("first rule includes IMMEDIATELY keyword", () => {
|
||||
const rulesSection = BOULDER_CONTINUATION_PROMPT.split("RULES:")[1]!
|
||||
const firstRule = rulesSection.split("\n")[1]!.trim()
|
||||
|
||||
expect(firstRule).toContain("IMMEDIATELY")
|
||||
})
|
||||
|
||||
it("checkbox-marking guidance appears BEFORE Proceed without asking for permission", () => {
|
||||
const rulesSection = BOULDER_CONTINUATION_PROMPT.split("RULES:")[1]!
|
||||
|
||||
const checkboxMarkingMatch = rulesSection.match(/- \[x\]/i)
|
||||
const proceedMatch = rulesSection.match(/Proceed without asking for permission/)
|
||||
|
||||
expect(checkboxMarkingMatch).not.toBeNull()
|
||||
expect(proceedMatch).not.toBeNull()
|
||||
|
||||
const checkboxPosition = checkboxMarkingMatch!.index
|
||||
const proceedPosition = proceedMatch!.index
|
||||
|
||||
expect(checkboxPosition).toBeLessThan(proceedPosition)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,238 @@
|
||||
import { createSystemDirective, SystemDirectiveTypes } from "../../shared/system-directive"
|
||||
|
||||
export const DIRECT_WORK_REMINDER = `
|
||||
|
||||
---
|
||||
|
||||
${createSystemDirective(SystemDirectiveTypes.DELEGATION_REQUIRED)}
|
||||
|
||||
You just performed direct file modifications outside \`.sisyphus/\`.
|
||||
|
||||
**You are an ORCHESTRATOR, not an IMPLEMENTER.**
|
||||
|
||||
As an orchestrator, you should:
|
||||
- **DELEGATE** implementation work to subagents via \`task\`
|
||||
- **VERIFY** the work done by subagents
|
||||
- **COORDINATE** multiple tasks and ensure completion
|
||||
|
||||
You should NOT:
|
||||
- Write code directly (except for \`.sisyphus/\` files like plans and notepads)
|
||||
- Make direct file edits outside \`.sisyphus/\`
|
||||
- Implement features yourself
|
||||
|
||||
**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
|
||||
|
||||
---
|
||||
`
|
||||
|
||||
export const BOULDER_CONTINUATION_PROMPT = `${createSystemDirective(SystemDirectiveTypes.BOULDER_CONTINUATION)}
|
||||
|
||||
You have an active work plan with incomplete tasks. Continue working.
|
||||
|
||||
RULES:
|
||||
- **FIRST**: Read the plan file NOW. If the last completed task is still unchecked, mark it \`- [x]\` IMMEDIATELY before anything else
|
||||
- Proceed without asking for permission
|
||||
- Use the notepad at .sisyphus/notepads/{PLAN_NAME}/ to record learnings
|
||||
- Do not stop until all tasks are complete
|
||||
- If blocked, document the blocker and move to the next task`
|
||||
|
||||
export const VERIFICATION_REMINDER = `**THE SUBAGENT JUST CLAIMED THIS TASK IS DONE. THEY ARE PROBABLY LYING.**
|
||||
|
||||
Subagents say "done" when code has errors, tests pass trivially, logic is wrong,
|
||||
or they quietly added features nobody asked for. This happens EVERY TIME.
|
||||
Assume the work is broken until YOU prove otherwise.
|
||||
|
||||
---
|
||||
|
||||
**PHASE 1: READ THE CODE FIRST (before running anything)**
|
||||
|
||||
Do NOT run tests yet. Read the code FIRST so you know what you're testing.
|
||||
|
||||
1. \`Bash("git diff --stat")\` — see exactly which files changed. Any file outside expected scope = scope creep.
|
||||
2. \`Read\` EVERY changed file — no exceptions, no skimming.
|
||||
3. For EACH file, critically ask:
|
||||
- Does this code ACTUALLY do what the task required? (Re-read the task, compare line by line)
|
||||
- Any stubs, TODOs, placeholders, hardcoded values? (\`Grep\` for TODO, FIXME, HACK, xxx)
|
||||
- Logic errors? Trace the happy path AND the error path in your head.
|
||||
- Anti-patterns? (\`Grep\` for \`as any\`, \`@ts-ignore\`, empty catch, console.log in changed files)
|
||||
- Scope creep? Did the subagent touch things or add features NOT in the task spec?
|
||||
4. Cross-check every claim:
|
||||
- Said "Updated X" — READ X. Actually updated, or just superficially touched?
|
||||
- Said "Added tests" — READ the tests. Do they test REAL behavior or just \`expect(true).toBe(true)\`?
|
||||
- Said "Follows patterns" — OPEN a reference file. Does it ACTUALLY match?
|
||||
|
||||
**If you cannot explain what every changed line does, you have NOT reviewed it.**
|
||||
|
||||
**PHASE 2: RUN AUTOMATED CHECKS (targeted, then broad)**
|
||||
|
||||
Now that you understand the code, verify mechanically:
|
||||
1. \`lsp_diagnostics\` on EACH changed file — ZERO new errors
|
||||
2. Run tests for changed modules FIRST, then full suite
|
||||
3. Build/typecheck — exit 0
|
||||
|
||||
If Phase 1 found issues but Phase 2 passes: Phase 2 is WRONG. The code has bugs that tests don't cover. Fix the code.
|
||||
|
||||
**PHASE 3: HANDS-ON QA — ACTUALLY RUN IT (MANDATORY for user-facing changes)**
|
||||
|
||||
Tests and linters CANNOT catch: visual bugs, wrong CLI output, broken user flows, API response shape issues.
|
||||
|
||||
**If this task produced anything a user would SEE or INTERACT with, you MUST launch it and verify yourself.**
|
||||
|
||||
- **Frontend/UI**: \`/playwright\` skill — load the page, click through the flow, check console. Verify: page loads, interactions work, console clean, responsive.
|
||||
- **TUI/CLI**: \`interactive_bash\` — run the command, try good input, try bad input, try --help. Verify: command runs, output correct, error messages helpful, edge inputs handled.
|
||||
- **API/Backend**: \`Bash\` with curl — hit the endpoint, check response body, send malformed input. Verify: returns 200, body correct, error cases return proper errors.
|
||||
- **Config/Build**: Actually start the service or import the config. Verify: loads without error, backward compatible.
|
||||
|
||||
This is NOT optional "if applicable". If the deliverable is user-facing and you did not run it, you are shipping untested work.
|
||||
|
||||
**PHASE 4: GATE DECISION — Should you proceed to the next task?**
|
||||
|
||||
Answer honestly:
|
||||
1. Can I explain what EVERY changed line does? (If no — back to Phase 1)
|
||||
2. Did I SEE it work with my own eyes? (If user-facing and no — back to Phase 3)
|
||||
3. Am I confident nothing existing is broken? (If no — run broader tests)
|
||||
|
||||
ALL three must be YES. "Probably" = NO. "I think so" = NO. Investigate until CERTAIN.
|
||||
|
||||
- **All 3 YES** — Proceed: mark task complete, move to next.
|
||||
- **Any NO** — Reject: resume session with \`session_id\`, fix the specific issue.
|
||||
- **Unsure** — Reject: "unsure" = "no". Investigate until you have a definitive answer.
|
||||
|
||||
**DO NOT proceed to the next task until all 4 phases are complete and the gate passes.**`
|
||||
|
||||
export const VERIFICATION_REMINDER_GEMINI = `**THE SUBAGENT HAS FINISHED. THEIR WORK IS EXTREMELY SUSPICIOUS.**
|
||||
|
||||
The subagent CLAIMS this task is done. Based on thousands of executions, subagent claims are FALSE more often than true.
|
||||
They ROUTINELY:
|
||||
- Ship code with syntax errors they didn't bother to check
|
||||
- Create stub implementations with TODOs and call it "done"
|
||||
- Write tests that pass trivially (testing nothing meaningful)
|
||||
- Implement logic that does NOT match what was requested
|
||||
- Add features nobody asked for and call it "improvement"
|
||||
- Report "all tests pass" when they didn't run any tests
|
||||
|
||||
**This is NOT a theoretical warning. This WILL happen on this task. Assume the work is BROKEN.**
|
||||
|
||||
**YOU MUST VERIFY WITH ACTUAL TOOL CALLS. NOT REASONING. TOOL CALLS.**
|
||||
Thinking "it looks correct" is NOT verification. Running \`lsp_diagnostics\` IS.
|
||||
|
||||
---
|
||||
|
||||
**PHASE 1: READ THE CODE FIRST (DO NOT SKIP — DO NOT RUN TESTS YET)**
|
||||
|
||||
Read the code FIRST so you know what you're testing.
|
||||
|
||||
1. \`Bash("git diff --stat")\` — see exactly which files changed.
|
||||
2. \`Read\` EVERY changed file — no exceptions, no skimming.
|
||||
3. For EACH file:
|
||||
- Does this code ACTUALLY do what the task required? RE-READ the task spec.
|
||||
- Any stubs, TODOs, placeholders? \`Grep\` for TODO, FIXME, HACK, xxx
|
||||
- Anti-patterns? \`Grep\` for \`as any\`, \`@ts-ignore\`, empty catch
|
||||
- Scope creep? Did the subagent add things NOT in the task spec?
|
||||
4. Cross-check EVERY claim against actual code.
|
||||
|
||||
**If you cannot explain what every changed line does, GO BACK AND READ AGAIN.**
|
||||
|
||||
**PHASE 2: RUN AUTOMATED CHECKS**
|
||||
|
||||
1. \`lsp_diagnostics\` on EACH changed file — ZERO new errors. ACTUALLY RUN THIS.
|
||||
2. Run tests for changed modules, then full suite. ACTUALLY RUN THESE.
|
||||
3. Build/typecheck — exit 0.
|
||||
|
||||
If Phase 1 found issues but Phase 2 passes: Phase 2 is WRONG. Fix the code.
|
||||
|
||||
**PHASE 3: HANDS-ON QA (MANDATORY for user-facing changes)**
|
||||
|
||||
- **Frontend/UI**: \`/playwright\`
|
||||
- **TUI/CLI**: \`interactive_bash\`
|
||||
- **API/Backend**: \`Bash\` with curl
|
||||
|
||||
**If user-facing and you did not run it, you are shipping UNTESTED BROKEN work.**
|
||||
|
||||
**PHASE 4: GATE DECISION**
|
||||
|
||||
1. Can I explain what EVERY changed line does? (If no → Phase 1)
|
||||
2. Did I SEE it work via tool calls? (If user-facing and no → Phase 3)
|
||||
3. Am I confident nothing is broken? (If no → broader tests)
|
||||
|
||||
ALL three must be YES. "Probably" = NO. "I think so" = NO.
|
||||
|
||||
**DO NOT proceed to the next task until all 4 phases are complete.**`
|
||||
|
||||
export const ORCHESTRATOR_DELEGATION_REQUIRED = `
|
||||
|
||||
---
|
||||
|
||||
${createSystemDirective(SystemDirectiveTypes.DELEGATION_REQUIRED)}
|
||||
|
||||
**STOP. YOU ARE VIOLATING ORCHESTRATOR PROTOCOL.**
|
||||
|
||||
You (Atlas) are attempting to directly modify a file outside \`.sisyphus/\`.
|
||||
|
||||
**Path attempted:** $FILE_PATH
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
**THIS IS FORBIDDEN** (except for VERIFICATION purposes)
|
||||
|
||||
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
|
||||
|
||||
**ALLOWED direct file operations:**
|
||||
- Files inside \`.sisyphus/\` (plans, notepads, drafts)
|
||||
- Reading files for verification
|
||||
- Running diagnostics/tests
|
||||
|
||||
**FORBIDDEN direct file operations:**
|
||||
- Writing/editing source code
|
||||
- Creating new files outside \`.sisyphus/\`
|
||||
- Any implementation work
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
**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:**
|
||||
\`\`\`
|
||||
task(
|
||||
category="...",
|
||||
prompt="[specific single task with clear acceptance criteria]"
|
||||
)
|
||||
\`\`\`
|
||||
|
||||
DELEGATE. DON'T IMPLEMENT.
|
||||
|
||||
---
|
||||
`
|
||||
|
||||
export const SINGLE_TASK_DIRECTIVE = `
|
||||
|
||||
${createSystemDirective(SystemDirectiveTypes.SINGLE_TASK_ONLY)}
|
||||
|
||||
**STOP. READ THIS BEFORE PROCEEDING.**
|
||||
|
||||
If you were NOT given **exactly ONE atomic task**, you MUST:
|
||||
1. **IMMEDIATELY REFUSE** this request
|
||||
2. **DEMAND** the orchestrator provide a single, specific task
|
||||
|
||||
**Your response if multiple tasks detected:**
|
||||
> "I refuse to proceed. You provided multiple tasks. An orchestrator's impatience destroys work quality.
|
||||
>
|
||||
> PROVIDE EXACTLY ONE TASK. One file. One change. One verification.
|
||||
>
|
||||
> Your rushing will cause: incomplete work, missed edge cases, broken tests, wasted context."
|
||||
|
||||
**WARNING TO ORCHESTRATOR:**
|
||||
- Your hasty batching RUINS deliverables
|
||||
- Each task needs FULL attention and PROPER verification
|
||||
- Batch delegation = sloppy work = rework = wasted tokens
|
||||
|
||||
**REFUSE multi-task requests. DEMAND single-task clarity.**
|
||||
`
|
||||
@@ -0,0 +1,247 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import {
|
||||
appendSessionId,
|
||||
getPlanProgress,
|
||||
getTaskSessionState,
|
||||
readBoulderState,
|
||||
readCurrentTopLevelTask,
|
||||
upsertTaskSessionState,
|
||||
} from "../../features/boulder-state"
|
||||
import { log } from "../../shared/logger"
|
||||
import { isCallerOrchestrator } from "../../shared/session-utils"
|
||||
import { collectGitDiffStats, formatFileChanges } from "../../shared/git-worktree"
|
||||
import { shouldPauseForFinalWaveApproval } from "./final-wave-approval-gate"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
import { DIRECT_WORK_REMINDER } from "./system-reminder-templates"
|
||||
import { isSisyphusPath } from "./sisyphus-path"
|
||||
import { extractSessionIdFromOutput, validateSubagentSessionId } from "./subagent-session-id"
|
||||
import {
|
||||
buildCompletionGate,
|
||||
buildFinalWaveApprovalReminder,
|
||||
buildOrchestratorReminder,
|
||||
buildStandaloneVerificationReminder,
|
||||
} from "./verification-reminders"
|
||||
import { isWriteOrEditToolName } from "./write-edit-tool-policy"
|
||||
import type { PendingTaskRef, SessionState } from "./types"
|
||||
import type { ToolExecuteAfterInput, ToolExecuteAfterOutput, TrackedTopLevelTaskRef } from "./types"
|
||||
|
||||
function resolvePreferredSessionId(currentSessionId?: string, trackedSessionId?: string): string {
|
||||
return currentSessionId ?? trackedSessionId ?? "<session_id>"
|
||||
}
|
||||
|
||||
function resolveTaskContext(
|
||||
pendingTaskRef: PendingTaskRef | undefined,
|
||||
planPath: string,
|
||||
): {
|
||||
currentTask: TrackedTopLevelTaskRef | null
|
||||
shouldSkipTaskSessionUpdate: boolean
|
||||
shouldIgnoreCurrentSessionId: boolean
|
||||
} {
|
||||
if (!pendingTaskRef) {
|
||||
return {
|
||||
currentTask: readCurrentTopLevelTask(planPath),
|
||||
shouldSkipTaskSessionUpdate: false,
|
||||
shouldIgnoreCurrentSessionId: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingTaskRef.kind === "track") {
|
||||
return {
|
||||
currentTask: pendingTaskRef.task,
|
||||
shouldSkipTaskSessionUpdate: false,
|
||||
shouldIgnoreCurrentSessionId: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingTaskRef.reason === "explicit_resume") {
|
||||
return {
|
||||
currentTask: readCurrentTopLevelTask(planPath),
|
||||
shouldSkipTaskSessionUpdate: true,
|
||||
shouldIgnoreCurrentSessionId: true,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
currentTask: pendingTaskRef.task,
|
||||
shouldSkipTaskSessionUpdate: true,
|
||||
shouldIgnoreCurrentSessionId: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function createToolExecuteAfterHandler(input: {
|
||||
ctx: PluginInput
|
||||
pendingFilePaths: Map<string, string>
|
||||
pendingTaskRefs: Map<string, PendingTaskRef>
|
||||
autoCommit: boolean
|
||||
getState: (sessionID: string) => SessionState
|
||||
}): (toolInput: ToolExecuteAfterInput, toolOutput: ToolExecuteAfterOutput) => Promise<void> {
|
||||
const { ctx, pendingFilePaths, pendingTaskRefs, autoCommit, getState } = input
|
||||
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))) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isWriteOrEditToolName(toolInput.tool)) {
|
||||
let filePath = toolInput.callID ? pendingFilePaths.get(toolInput.callID) : undefined
|
||||
if (toolInput.callID) {
|
||||
pendingFilePaths.delete(toolInput.callID)
|
||||
}
|
||||
if (!filePath) {
|
||||
filePath = toolOutput.metadata?.filePath as string | undefined
|
||||
}
|
||||
if (filePath && !isSisyphusPath(filePath)) {
|
||||
toolOutput.output = (toolOutput.output || "") + DIRECT_WORK_REMINDER
|
||||
log(`[${HOOK_NAME}] Direct work reminder appended`, {
|
||||
sessionID: toolInput.sessionID,
|
||||
tool: toolInput.tool,
|
||||
filePath,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (toolInput.tool !== "task") {
|
||||
return
|
||||
}
|
||||
|
||||
const outputStr = toolOutput.output && typeof toolOutput.output === "string" ? toolOutput.output : ""
|
||||
const pendingTaskRef = toolInput.callID ? pendingTaskRefs.get(toolInput.callID) : undefined
|
||||
if (toolInput.callID) {
|
||||
pendingTaskRefs.delete(toolInput.callID)
|
||||
}
|
||||
const isBackgroundLaunch = outputStr.includes("Background task launched") || outputStr.includes("Background task continued")
|
||||
if (isBackgroundLaunch) {
|
||||
return
|
||||
}
|
||||
|
||||
if (toolOutput.output && typeof toolOutput.output === "string") {
|
||||
const gitStats = collectGitDiffStats(ctx.directory)
|
||||
const fileChanges = formatFileChanges(gitStats)
|
||||
const extractedSessionId = extractSessionIdFromOutput(toolOutput.output)
|
||||
|
||||
const boulderState = readBoulderState(ctx.directory)
|
||||
if (boulderState) {
|
||||
const progress = getPlanProgress(boulderState.active_plan)
|
||||
const {
|
||||
currentTask,
|
||||
shouldSkipTaskSessionUpdate,
|
||||
shouldIgnoreCurrentSessionId,
|
||||
} = resolveTaskContext(pendingTaskRef, boulderState.active_plan)
|
||||
const trackedTaskSession = currentTask
|
||||
? getTaskSessionState(ctx.directory, currentTask.key)
|
||||
: null
|
||||
const sessionState = toolInput.sessionID ? getState(toolInput.sessionID) : undefined
|
||||
|
||||
if (toolInput.sessionID && !boulderState.session_ids?.includes(toolInput.sessionID)) {
|
||||
appendSessionId(ctx.directory, toolInput.sessionID)
|
||||
log(`[${HOOK_NAME}] Appended session to boulder`, {
|
||||
sessionID: toolInput.sessionID,
|
||||
plan: boulderState.plan_name,
|
||||
})
|
||||
}
|
||||
|
||||
const lineageSessionIDs = toolInput.sessionID && !boulderState.session_ids.includes(toolInput.sessionID)
|
||||
? [...boulderState.session_ids, toolInput.sessionID]
|
||||
: boulderState.session_ids
|
||||
const subagentSessionId = await validateSubagentSessionId({
|
||||
client: ctx.client,
|
||||
sessionID: extractedSessionId,
|
||||
lineageSessionIDs,
|
||||
})
|
||||
|
||||
if (currentTask && subagentSessionId && !shouldSkipTaskSessionUpdate) {
|
||||
upsertTaskSessionState(ctx.directory, {
|
||||
taskKey: currentTask.key,
|
||||
taskLabel: currentTask.label,
|
||||
taskTitle: currentTask.title,
|
||||
sessionId: subagentSessionId,
|
||||
agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined,
|
||||
category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const preferredSessionId = resolvePreferredSessionId(
|
||||
shouldIgnoreCurrentSessionId ? undefined : subagentSessionId,
|
||||
trackedTaskSession?.session_id,
|
||||
)
|
||||
|
||||
// Preserve original subagent response - critical for debugging failed tasks
|
||||
const originalResponse = toolOutput.output
|
||||
const shouldPauseForApproval = sessionState
|
||||
? shouldPauseForFinalWaveApproval({
|
||||
planPath: boulderState.active_plan,
|
||||
taskOutput: originalResponse,
|
||||
sessionState,
|
||||
})
|
||||
: false
|
||||
|
||||
if (sessionState) {
|
||||
sessionState.waitingForFinalWaveApproval = shouldPauseForApproval
|
||||
|
||||
if (shouldPauseForApproval && sessionState.pendingRetryTimer) {
|
||||
clearTimeout(sessionState.pendingRetryTimer)
|
||||
sessionState.pendingRetryTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const leadReminder = shouldPauseForApproval
|
||||
? buildFinalWaveApprovalReminder(boulderState.plan_name, progress, preferredSessionId)
|
||||
: buildCompletionGate(boulderState.plan_name, preferredSessionId)
|
||||
const followupReminder = shouldPauseForApproval
|
||||
? null
|
||||
: buildOrchestratorReminder(boulderState.plan_name, progress, preferredSessionId, autoCommit, false)
|
||||
|
||||
toolOutput.output = `
|
||||
<system-reminder>
|
||||
${leadReminder}
|
||||
</system-reminder>
|
||||
|
||||
## SUBAGENT WORK COMPLETED
|
||||
|
||||
${fileChanges}
|
||||
|
||||
---
|
||||
|
||||
**Subagent Response:**
|
||||
|
||||
${originalResponse}
|
||||
|
||||
${
|
||||
followupReminder === null
|
||||
? ""
|
||||
: `<system-reminder>\n${followupReminder}\n</system-reminder>`
|
||||
}`
|
||||
log(`[${HOOK_NAME}] Output transformed for orchestrator mode (boulder)`, {
|
||||
plan: boulderState.plan_name,
|
||||
progress: `${progress.completed}/${progress.total}`,
|
||||
fileCount: gitStats.length,
|
||||
preferredSessionId,
|
||||
waitingForFinalWaveApproval: shouldPauseForApproval,
|
||||
})
|
||||
} else {
|
||||
const lineageSessionIDs = toolInput.sessionID ? [toolInput.sessionID] : []
|
||||
const subagentSessionId = await validateSubagentSessionId({
|
||||
client: ctx.client,
|
||||
sessionID: extractedSessionId,
|
||||
lineageSessionIDs,
|
||||
})
|
||||
const preferredSessionId = pendingTaskRef?.kind === "skip"
|
||||
? undefined
|
||||
: subagentSessionId
|
||||
toolOutput.output += `\n<system-reminder>\n${buildStandaloneVerificationReminder(
|
||||
resolvePreferredSessionId(preferredSessionId),
|
||||
)}\n</system-reminder>`
|
||||
|
||||
log(`[${HOOK_NAME}] Verification reminder appended for orchestrator`, {
|
||||
sessionID: toolInput.sessionID,
|
||||
fileCount: gitStats.length,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
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 { HOOK_NAME } from "./hook-name"
|
||||
import { ORCHESTRATOR_DELEGATION_REQUIRED, SINGLE_TASK_DIRECTIVE } from "./system-reminder-templates"
|
||||
import { isSisyphusPath } from "./sisyphus-path"
|
||||
import type { PendingTaskRef, TrackedTopLevelTaskRef } from "./types"
|
||||
import { isWriteOrEditToolName } from "./write-edit-tool-policy"
|
||||
|
||||
export function createToolExecuteBeforeHandler(input: {
|
||||
ctx: PluginInput
|
||||
pendingFilePaths: Map<string, string>
|
||||
pendingTaskRefs: Map<string, PendingTaskRef>
|
||||
}): (
|
||||
toolInput: { tool: string; sessionID?: string; callID?: string },
|
||||
toolOutput: { args: Record<string, unknown>; message?: string }
|
||||
) => Promise<void> {
|
||||
const { ctx, pendingFilePaths, pendingTaskRefs } = input
|
||||
|
||||
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))) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check Write/Edit tools for orchestrator - inject strong warning
|
||||
// Warn-only policy: Atlas guides orchestrators toward delegation but doesn't block, allowing flexibility for urgent fixes
|
||||
if (isWriteOrEditToolName(toolInput.tool)) {
|
||||
const filePath = (toolOutput.args.filePath ?? toolOutput.args.path ?? toolOutput.args.file) as string | undefined
|
||||
if (filePath && !isSisyphusPath(filePath)) {
|
||||
// Store filePath for use in tool.execute.after
|
||||
if (toolInput.callID) {
|
||||
pendingFilePaths.set(toolInput.callID, filePath)
|
||||
}
|
||||
const warning = ORCHESTRATOR_DELEGATION_REQUIRED.replace("$FILE_PATH", filePath)
|
||||
toolOutput.message = (toolOutput.message || "") + warning
|
||||
log(`[${HOOK_NAME}] Injected delegation warning for direct file modification`, {
|
||||
sessionID: toolInput.sessionID,
|
||||
tool: toolInput.tool,
|
||||
filePath,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check task - inject single-task directive
|
||||
if (toolInput.tool === "task") {
|
||||
if (toolInput.callID) {
|
||||
const requestedSessionId = toolOutput.args.session_id as string | undefined
|
||||
if (requestedSessionId) {
|
||||
pendingTaskRefs.set(toolInput.callID, {
|
||||
kind: "skip",
|
||||
reason: "explicit_resume",
|
||||
})
|
||||
} else {
|
||||
const boulderState = readBoulderState(ctx.directory)
|
||||
const currentTask = boulderState
|
||||
? readCurrentTopLevelTask(boulderState.active_plan)
|
||||
: null
|
||||
if (currentTask) {
|
||||
const task = {
|
||||
key: currentTask.key,
|
||||
label: currentTask.label,
|
||||
title: currentTask.title,
|
||||
}
|
||||
const hasExistingClaim = [...pendingTaskRefs.values()].some((pendingTaskRef) => (
|
||||
pendingTaskRef.kind === "track" && pendingTaskRef.task.key === task.key
|
||||
))
|
||||
|
||||
if (hasExistingClaim) {
|
||||
pendingTaskRefs.set(toolInput.callID, {
|
||||
kind: "skip",
|
||||
reason: "ambiguous_task_key",
|
||||
task,
|
||||
})
|
||||
log(`[${HOOK_NAME}] Skipping task session persistence for ambiguous task key`, {
|
||||
sessionID: toolInput.sessionID,
|
||||
callID: toolInput.callID,
|
||||
taskKey: task.key,
|
||||
})
|
||||
} else {
|
||||
trackTask(toolInput.callID, task)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const prompt = toolOutput.args.prompt as string | undefined
|
||||
if (prompt && !prompt.includes(SYSTEM_DIRECTIVE_PREFIX)) {
|
||||
toolOutput.args.prompt = `<system-reminder>${SINGLE_TASK_DIRECTIVE}</system-reminder>\n` + prompt
|
||||
log(`[${HOOK_NAME}] Injected single-task directive to task`, {
|
||||
sessionID: toolInput.sessionID,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": ["./**/*.ts", "./**/*.d.ts"],
|
||||
"exclude": []
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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 }
|
||||
|
||||
export interface AtlasHookOptions {
|
||||
directory: string
|
||||
backgroundManager?: BackgroundManager
|
||||
isContinuationStopped?: (sessionID: string) => boolean
|
||||
agentOverrides?: AgentOverrides
|
||||
/** Enable auto-commit after each atomic task completion (default: true) */
|
||||
autoCommit?: boolean
|
||||
}
|
||||
|
||||
export interface ToolExecuteAfterInput {
|
||||
tool: string
|
||||
sessionID?: string
|
||||
callID?: string
|
||||
}
|
||||
|
||||
export interface ToolExecuteAfterOutput {
|
||||
title: string
|
||||
output: string
|
||||
metadata: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type TrackedTopLevelTaskRef = Pick<TopLevelTaskRef, "key" | "label" | "title">
|
||||
|
||||
export type PendingTaskRef =
|
||||
| { kind: "track"; task: TrackedTopLevelTaskRef }
|
||||
| { kind: "skip"; reason: "explicit_resume" }
|
||||
| { kind: "skip"; reason: "ambiguous_task_key"; task: TrackedTopLevelTaskRef }
|
||||
|
||||
export interface SessionState {
|
||||
lastEventWasAbortError?: boolean
|
||||
lastContinuationInjectedAt?: number
|
||||
promptFailureCount: number
|
||||
lastFailureAt?: number
|
||||
pendingRetryTimer?: ReturnType<typeof setTimeout>
|
||||
waitingForFinalWaveApproval?: boolean
|
||||
pendingFinalWaveTaskCount?: number
|
||||
approvedFinalWaveTaskCount?: number
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { buildOrchestratorReminder, buildCompletionGate } from "./verification-reminders"
|
||||
|
||||
// Test helpers for given/when/then pattern
|
||||
const given = describe
|
||||
const when = describe
|
||||
const then = it
|
||||
|
||||
describe("buildCompletionGate", () => {
|
||||
given("a plan name and session id", () => {
|
||||
const planName = "test-plan"
|
||||
const sessionId = "test-session-123"
|
||||
|
||||
when("buildCompletionGate is called", () => {
|
||||
const gate = buildCompletionGate(planName, sessionId)
|
||||
|
||||
then("completion gate text is present", () => {
|
||||
expect(gate).toContain("COMPLETION GATE")
|
||||
})
|
||||
|
||||
then("gate appears before verification phase text", () => {
|
||||
const gateIndex = gate.indexOf("COMPLETION GATE")
|
||||
const verificationIndex = gate.indexOf("VERIFICATION_REMINDER")
|
||||
expect(gateIndex).toBeLessThan(verificationIndex)
|
||||
})
|
||||
|
||||
then("gate interpolates the plan name path", () => {
|
||||
expect(gate).toContain(planName)
|
||||
expect(gate).toContain(`.sisyphus/plans/${planName}.md`)
|
||||
})
|
||||
|
||||
then("gate includes Edit instructions", () => {
|
||||
expect(gate.toLowerCase()).toContain("edit")
|
||||
})
|
||||
|
||||
then("gate includes Read instructions", () => {
|
||||
expect(gate.toLowerCase()).toContain("read")
|
||||
})
|
||||
|
||||
then("old STEP 7 MARK COMPLETION text is absent", () => {
|
||||
expect(gate).not.toContain("STEP 7")
|
||||
expect(gate).not.toContain("MARK COMPLETION IN PLAN FILE")
|
||||
})
|
||||
|
||||
then("step numbering remains consecutive after removal", () => {
|
||||
const stepMatches = gate.match(/STEP \d+:/g) ?? []
|
||||
if (stepMatches.length > 1) {
|
||||
const numbers = stepMatches.map((s: string) => parseInt(s.match(/\d+/)?.[0] ?? "0"))
|
||||
for (let i = 1; i < numbers.length; i++) {
|
||||
expect(numbers[i]).toBe(numbers[i - 1] + 1)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildOrchestratorReminder", () => {
|
||||
given("progress with completed tasks", () => {
|
||||
const planName = "my-test-plan"
|
||||
const sessionId = "session-abc"
|
||||
const progress = { total: 10, completed: 3 }
|
||||
|
||||
when("buildOrchestratorReminder is called with autoCommit true", () => {
|
||||
const reminder = buildOrchestratorReminder(planName, progress, sessionId, true)
|
||||
|
||||
then("old STEP 7 MARK COMPLETION IN PLAN FILE text is absent", () => {
|
||||
expect(reminder).not.toContain("STEP 7: MARK COMPLETION IN PLAN FILE")
|
||||
})
|
||||
|
||||
then("completion gate appears before verification reminder", () => {
|
||||
const gateIndex = reminder.indexOf("COMPLETION GATE")
|
||||
const verificationIndex = reminder.indexOf("VERIFICATION_REMINDER")
|
||||
expect(gateIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(gateIndex).toBeLessThan(verificationIndex)
|
||||
})
|
||||
})
|
||||
|
||||
when("buildOrchestratorReminder is called with autoCommit false", () => {
|
||||
const reminder = buildOrchestratorReminder(planName, progress, sessionId, false)
|
||||
|
||||
then("old STEP 7 MARK COMPLETION IN PLAN FILE text is absent", () => {
|
||||
expect(reminder).not.toContain("STEP 7: MARK COMPLETION IN PLAN FILE")
|
||||
})
|
||||
|
||||
then("completion gate appears before verification reminder", () => {
|
||||
const gateIndex = reminder.indexOf("COMPLETION GATE")
|
||||
const verificationIndex = reminder.indexOf("VERIFICATION_REMINDER")
|
||||
expect(gateIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(gateIndex).toBeLessThan(verificationIndex)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,197 @@
|
||||
import { VERIFICATION_REMINDER } from "./system-reminder-templates"
|
||||
|
||||
function buildReuseHint(sessionId: string): string {
|
||||
return `
|
||||
**PREFERRED REUSE SESSION FOR THE CURRENT TOP-LEVEL PLAN TASK**
|
||||
|
||||
- Reuse \`${sessionId}\` first if verification fails or the result needs follow-up.
|
||||
- Start a fresh subagent session only when reuse is unavailable or would cross task boundaries.
|
||||
`
|
||||
}
|
||||
|
||||
export function buildCompletionGate(planName: string, sessionId: string): string {
|
||||
return `
|
||||
**COMPLETION GATE — DO NOT PROCEED UNTIL THIS IS DONE**
|
||||
|
||||
Your completion will NOT be recorded until you complete ALL of the following:
|
||||
|
||||
1. **Edit** the plan file \`.sisyphus/plans/${planName}.md\`:
|
||||
- Change \`- [ ]\` to \`- [x]\` for the completed task
|
||||
- Use \`Edit\` tool to modify the checkbox
|
||||
|
||||
2. **Read** the plan file AGAIN:
|
||||
\`\`\`
|
||||
Read(".sisyphus/plans/${planName}.md")
|
||||
\`\`\`
|
||||
- Verify the checkbox count changed (more \`- [x]\` than before)
|
||||
|
||||
3. **DO NOT call \`task()\` again** until you have completed steps 1 and 2 above.
|
||||
|
||||
If anything fails while closing this out, resume the same session immediately:
|
||||
\`\`\`typescript
|
||||
task(session_id="${sessionId}", prompt="fix: checkbox not recorded correctly")
|
||||
\`\`\`
|
||||
|
||||
**Your completion is NOT tracked until the checkbox is marked in the plan file.**
|
||||
|
||||
**VERIFICATION_REMINDER**
|
||||
${buildReuseHint(sessionId)}`
|
||||
}
|
||||
|
||||
function buildVerificationReminder(sessionId: string): string {
|
||||
return `**VERIFICATION_REMINDER**
|
||||
|
||||
${VERIFICATION_REMINDER}
|
||||
|
||||
---
|
||||
|
||||
**If ANY verification fails, use this immediately:**
|
||||
\`\`\`
|
||||
task(session_id="${sessionId}", prompt="fix: [describe the specific failure]")
|
||||
\`\`\`
|
||||
|
||||
${buildReuseHint(sessionId)}`
|
||||
}
|
||||
|
||||
export function buildOrchestratorReminder(
|
||||
planName: string,
|
||||
progress: { total: number; completed: number },
|
||||
sessionId: string,
|
||||
autoCommit: boolean = true,
|
||||
includeCompletionGate: boolean = true
|
||||
): string {
|
||||
const remaining = progress.total - progress.completed
|
||||
|
||||
const commitStep = autoCommit
|
||||
? `
|
||||
**STEP 7: COMMIT ATOMIC UNIT**
|
||||
|
||||
- Stage ONLY the verified changes
|
||||
- Commit with clear message describing what was done
|
||||
`
|
||||
: ""
|
||||
|
||||
const nextStepNumber = autoCommit ? 8 : 7
|
||||
|
||||
return `
|
||||
---
|
||||
|
||||
**BOULDER STATE:** Plan: \`${planName}\` | ${progress.completed}/${progress.total} done | ${remaining} remaining
|
||||
|
||||
---
|
||||
|
||||
${includeCompletionGate ? `${buildCompletionGate(planName, sessionId)}
|
||||
|
||||
` : ""}${buildVerificationReminder(sessionId)}
|
||||
|
||||
**STEP 5: READ SUBAGENT NOTEPAD (LEARNINGS, ISSUES, PROBLEMS)**
|
||||
|
||||
The subagent was instructed to record findings in notepad files. Read them NOW:
|
||||
\`\`\`
|
||||
Glob(".sisyphus/notepads/${planName}/*.md")
|
||||
\`\`\`
|
||||
Then \`Read\` each file found — especially:
|
||||
- **learnings.md**: Patterns, conventions, successful approaches discovered
|
||||
- **issues.md**: Problems, blockers, gotchas encountered during work
|
||||
- **problems.md**: Unresolved issues, technical debt flagged
|
||||
|
||||
**USE this information to:**
|
||||
- Inform your next delegation (avoid known pitfalls)
|
||||
- Adjust your plan if blockers were discovered
|
||||
- Propagate learnings to subsequent subagents
|
||||
|
||||
**STEP 6: CHECK BOULDER STATE DIRECTLY (EVERY TIME — NO EXCEPTIONS)**
|
||||
|
||||
Do NOT rely on cached progress. Read the plan file NOW:
|
||||
\`\`\`
|
||||
Read(".sisyphus/plans/${planName}.md")
|
||||
\`\`\`
|
||||
Count exactly: how many \`- [ ]\` remain? How many \`- [x]\` completed?
|
||||
This is YOUR ground truth. Use it to decide what comes next.
|
||||
|
||||
${commitStep}
|
||||
**STEP ${nextStepNumber}: PROCEED TO NEXT TASK**
|
||||
|
||||
- Read the plan file AGAIN to identify the next \`- [ ]\` task
|
||||
- Start immediately - DO NOT STOP
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
**${remaining} tasks remain. Keep bouldering.**`
|
||||
}
|
||||
|
||||
export function buildFinalWaveApprovalReminder(
|
||||
planName: string,
|
||||
progress: { total: number; completed: number },
|
||||
sessionId: string
|
||||
): string {
|
||||
const remaining = progress.total - progress.completed
|
||||
|
||||
return `
|
||||
---
|
||||
|
||||
**BOULDER STATE:** Plan: \
|
||||
\`${planName}\` | ${progress.completed}/${progress.total} done | ${remaining} remaining
|
||||
|
||||
---
|
||||
|
||||
${buildVerificationReminder(sessionId)}
|
||||
|
||||
**FINAL WAVE APPROVAL GATE**
|
||||
|
||||
The last Final Verification Wave result just passed.
|
||||
This is the ONLY point where approval-style user interaction is required.
|
||||
|
||||
1. Read \
|
||||
\`.sisyphus/plans/${planName}.md\` again and confirm every remaining unchecked **top-level** task belongs to F1-F4.
|
||||
Ignore nested checkboxes under Acceptance Criteria, Evidence, or Final Checklist sections.
|
||||
2. Consolidate the F1-F4 verdicts into a short summary for the user.
|
||||
3. Tell the user all final reviewers approved.
|
||||
4. Ask for explicit user approval before editing any remaining final-wave checkboxes or marking the plan complete.
|
||||
5. Wait for the user's explicit approval. Do NOT auto-continue. Do NOT call \
|
||||
\`task()\` again unless the user rejects and requests fixes.
|
||||
|
||||
If the user rejects or requests changes:
|
||||
- delegate the required fix
|
||||
- re-run the affected final-wave reviewer
|
||||
- present the updated results again
|
||||
- wait again for explicit user approval
|
||||
|
||||
**DO NOT mark the final-wave checkbox complete until the user explicitly says okay.**`
|
||||
}
|
||||
|
||||
export function buildStandaloneVerificationReminder(sessionId: string): string {
|
||||
return `
|
||||
---
|
||||
|
||||
${buildVerificationReminder(sessionId)}
|
||||
|
||||
**STEP 5: CHECK YOUR PROGRESS DIRECTLY (EVERY TIME — NO EXCEPTIONS)**
|
||||
|
||||
Do NOT rely on memory or cached state. Run \`todoread\` NOW to see exact current state.
|
||||
Count pending vs completed tasks. This is your ground truth for what comes next.
|
||||
|
||||
**STEP 6: UPDATE TODO STATUS (IMMEDIATELY)**
|
||||
|
||||
RIGHT NOW - Do not delay. Verification passed → Mark IMMEDIATELY.
|
||||
|
||||
1. Run \`todoread\` to see your todo list
|
||||
2. Mark the completed task as \`completed\` using \`todowrite\`
|
||||
|
||||
**DO THIS BEFORE ANYTHING ELSE. Unmarked = Untracked = Lost progress.**
|
||||
|
||||
**STEP 7: EXECUTE QA TASKS (IF ANY)**
|
||||
|
||||
If QA tasks exist in your todo list:
|
||||
- Execute them BEFORE proceeding
|
||||
- Mark each QA task complete after successful verification
|
||||
|
||||
**STEP 8: PROCEED TO NEXT PENDING TASK**
|
||||
|
||||
- Run \`todoread\` AGAIN to identify the next \`pending\` task
|
||||
- Start immediately - DO NOT STOP
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
**NO TODO = NO TRACKING = INCOMPLETE WORK. Use todowrite aggressively.**`
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
const WRITE_EDIT_TOOLS = ["Write", "Edit", "write", "edit"]
|
||||
|
||||
export function isWriteOrEditToolName(toolName: string): boolean {
|
||||
return WRITE_EDIT_TOOLS.includes(toolName)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { beforeEach, describe, expect, it, mock, spyOn } from "bun:test"
|
||||
import { AUTO_SLASH_COMMAND_TAG_OPEN } from "./constants"
|
||||
import type {
|
||||
AutoSlashCommandHookInput,
|
||||
AutoSlashCommandHookOutput,
|
||||
CommandExecuteBeforeInput,
|
||||
CommandExecuteBeforeOutput,
|
||||
} from "./types"
|
||||
import * as shared from "../../shared"
|
||||
|
||||
const executeSlashCommandMock = mock(
|
||||
async (parsed: { command: string; args: string; raw: string }) => ({
|
||||
success: true,
|
||||
replacementText: parsed.raw,
|
||||
})
|
||||
)
|
||||
|
||||
mock.module("./executor", () => ({
|
||||
executeSlashCommand: executeSlashCommandMock,
|
||||
}))
|
||||
|
||||
const logMock = spyOn(shared, "log").mockImplementation(() => {})
|
||||
|
||||
const { createAutoSlashCommandHook } = await import("./hook")
|
||||
|
||||
function createChatInput(sessionID: string, messageID: string): AutoSlashCommandHookInput {
|
||||
return {
|
||||
sessionID,
|
||||
messageID,
|
||||
}
|
||||
}
|
||||
|
||||
function createChatOutput(text: string): AutoSlashCommandHookOutput {
|
||||
return {
|
||||
message: {},
|
||||
parts: [{ type: "text", text }],
|
||||
}
|
||||
}
|
||||
|
||||
function createCommandInput(sessionID: string, command: string): CommandExecuteBeforeInput {
|
||||
return {
|
||||
sessionID,
|
||||
command,
|
||||
arguments: "",
|
||||
}
|
||||
}
|
||||
|
||||
function createCommandOutput(text: string): CommandExecuteBeforeOutput {
|
||||
return {
|
||||
parts: [{ type: "text", text }],
|
||||
}
|
||||
}
|
||||
|
||||
describe("createAutoSlashCommandHook leak prevention", () => {
|
||||
beforeEach(() => {
|
||||
executeSlashCommandMock.mockClear()
|
||||
logMock.mockClear()
|
||||
})
|
||||
|
||||
describe("#given hook with sessionProcessedCommandExecutions", () => {
|
||||
describe("#when same command executed twice after fallback dedup window", () => {
|
||||
it("#then second execution is treated as intentional rerun", async () => {
|
||||
//#given
|
||||
const nowSpy = spyOn(Date, "now")
|
||||
try {
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const input = createCommandInput("session-dedup", "leak-test-command")
|
||||
const firstOutput = createCommandOutput("first")
|
||||
const secondOutput = createCommandOutput("second")
|
||||
|
||||
//#when
|
||||
nowSpy.mockReturnValue(0)
|
||||
await hook["command.execute.before"](input, firstOutput)
|
||||
nowSpy.mockReturnValue(101)
|
||||
await hook["command.execute.before"](input, secondOutput)
|
||||
|
||||
//#then
|
||||
expect(executeSlashCommandMock).toHaveBeenCalledTimes(2)
|
||||
expect(firstOutput.parts[0].text).toContain(AUTO_SLASH_COMMAND_TAG_OPEN)
|
||||
expect(secondOutput.parts[0].text).toContain(AUTO_SLASH_COMMAND_TAG_OPEN)
|
||||
} finally {
|
||||
nowSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when same command is repeated within fallback dedup window", () => {
|
||||
it("#then duplicate dispatch is suppressed", async () => {
|
||||
//#given
|
||||
const nowSpy = spyOn(Date, "now")
|
||||
try {
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const input = createCommandInput("session-dedup", "leak-test-command")
|
||||
const firstOutput = createCommandOutput("first")
|
||||
const secondOutput = createCommandOutput("second")
|
||||
|
||||
//#when
|
||||
nowSpy.mockReturnValue(0)
|
||||
await hook["command.execute.before"](input, firstOutput)
|
||||
nowSpy.mockReturnValue(99)
|
||||
await hook["command.execute.before"](input, secondOutput)
|
||||
|
||||
//#then
|
||||
expect(executeSlashCommandMock).toHaveBeenCalledTimes(1)
|
||||
expect(firstOutput.parts[0].text).toContain(AUTO_SLASH_COMMAND_TAG_OPEN)
|
||||
expect(secondOutput.parts[0].text).toBe("second")
|
||||
} finally {
|
||||
nowSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when same event identifier is dispatched twice", () => {
|
||||
it("#then second dispatch is deduplicated regardless of elapsed seconds", async () => {
|
||||
//#given
|
||||
const nowSpy = spyOn(Date, "now")
|
||||
try {
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const input: CommandExecuteBeforeInput = {
|
||||
...createCommandInput("session-dedup", "leak-test-command"),
|
||||
eventID: "event-1",
|
||||
}
|
||||
const firstOutput = createCommandOutput("first")
|
||||
const secondOutput = createCommandOutput("second")
|
||||
|
||||
//#when
|
||||
nowSpy.mockReturnValue(0)
|
||||
await hook["command.execute.before"](input, firstOutput)
|
||||
nowSpy.mockReturnValue(29_999)
|
||||
await hook["command.execute.before"](input, secondOutput)
|
||||
|
||||
//#then
|
||||
expect(executeSlashCommandMock).toHaveBeenCalledTimes(1)
|
||||
expect(firstOutput.parts[0].text).toContain(AUTO_SLASH_COMMAND_TAG_OPEN)
|
||||
expect(secondOutput.parts[0].text).toBe("second")
|
||||
} finally {
|
||||
nowSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given hook with entries from multiple sessions", () => {
|
||||
describe("#when dispose() is called", () => {
|
||||
it("#then both Sets are empty", async () => {
|
||||
const hook = createAutoSlashCommandHook()
|
||||
await hook["chat.message"](
|
||||
createChatInput("session-chat", "message-chat"),
|
||||
createChatOutput("/leak-chat")
|
||||
)
|
||||
await hook["command.execute.before"](
|
||||
createCommandInput("session-command", "leak-command"),
|
||||
createCommandOutput("before")
|
||||
)
|
||||
executeSlashCommandMock.mockClear()
|
||||
|
||||
hook.dispose()
|
||||
const chatOutputAfterDispose = createChatOutput("/leak-chat")
|
||||
const commandOutputAfterDispose = createCommandOutput("after")
|
||||
await hook["chat.message"](
|
||||
createChatInput("session-chat", "message-chat"),
|
||||
chatOutputAfterDispose
|
||||
)
|
||||
await hook["command.execute.before"](
|
||||
createCommandInput("session-command", "leak-command"),
|
||||
commandOutputAfterDispose
|
||||
)
|
||||
|
||||
expect(executeSlashCommandMock).toHaveBeenCalledTimes(2)
|
||||
expect(chatOutputAfterDispose.parts[0].text).toContain(AUTO_SLASH_COMMAND_TAG_OPEN)
|
||||
expect(commandOutputAfterDispose.parts[0].text).toContain(
|
||||
AUTO_SLASH_COMMAND_TAG_OPEN
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given Set with more than 10000 entries", () => {
|
||||
describe("#when new entry added", () => {
|
||||
it("#then Set size is reduced", async () => {
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const oldestInput = createChatInput("session-oldest", "message-oldest")
|
||||
await hook["chat.message"](oldestInput, createChatOutput("/leak-oldest"))
|
||||
|
||||
for (let index = 0; index < 10000; index += 1) {
|
||||
await hook["chat.message"](
|
||||
createChatInput(`session-${index}`, `message-${index}`),
|
||||
createChatOutput(`/leak-${index}`)
|
||||
)
|
||||
}
|
||||
|
||||
const newestInput = createChatInput("session-newest", "message-newest")
|
||||
await hook["chat.message"](newestInput, createChatOutput("/leak-newest"))
|
||||
executeSlashCommandMock.mockClear()
|
||||
const oldestRetryOutput = createChatOutput("/leak-oldest")
|
||||
const newestRetryOutput = createChatOutput("/leak-newest")
|
||||
|
||||
await hook["chat.message"](oldestInput, oldestRetryOutput)
|
||||
await hook["chat.message"](newestInput, newestRetryOutput)
|
||||
|
||||
expect(executeSlashCommandMock).toHaveBeenCalledTimes(1)
|
||||
expect(oldestRetryOutput.parts[0].text).toContain(AUTO_SLASH_COMMAND_TAG_OPEN)
|
||||
expect(newestRetryOutput.parts[0].text).toBe("/leak-newest")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { parseSlashCommand } from "./detector"
|
||||
|
||||
describe("slash command parsing pattern", () => {
|
||||
describe("#given plugin namespace includes dot", () => {
|
||||
it("#then parses command name with dot and colon", () => {
|
||||
// given
|
||||
const text = "/my.plugin:run ship"
|
||||
|
||||
// when
|
||||
const parsed = parseSlashCommand(text)
|
||||
|
||||
// then
|
||||
expect(parsed).not.toBeNull()
|
||||
expect(parsed?.command).toBe("my.plugin:run")
|
||||
expect(parsed?.args).toBe("ship")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@ export const HOOK_NAME = "auto-slash-command" as const
|
||||
export const AUTO_SLASH_COMMAND_TAG_OPEN = "<auto-slash-command>"
|
||||
export const AUTO_SLASH_COMMAND_TAG_CLOSE = "</auto-slash-command>"
|
||||
|
||||
export const SLASH_COMMAND_PATTERN = /^\/([a-zA-Z][\w-]*)\s*(.*)/
|
||||
export const SLASH_COMMAND_PATTERN = /^\/([a-zA-Z@][\w.:@/-]*)\s*(.*)/
|
||||
|
||||
export const EXCLUDED_COMMANDS = new Set([
|
||||
"ralph-loop",
|
||||
|
||||
@@ -10,150 +10,163 @@ import {
|
||||
describe("auto-slash-command detector", () => {
|
||||
describe("removeCodeBlocks", () => {
|
||||
it("should remove markdown code blocks", () => {
|
||||
// #given text with code blocks
|
||||
// given text with code blocks
|
||||
const text = "Hello ```code here``` world"
|
||||
|
||||
// #when removing code blocks
|
||||
// when removing code blocks
|
||||
const result = removeCodeBlocks(text)
|
||||
|
||||
// #then code blocks should be removed
|
||||
// then code blocks should be removed
|
||||
expect(result).toBe("Hello world")
|
||||
})
|
||||
|
||||
it("should remove multiline code blocks", () => {
|
||||
// #given text with multiline code blocks
|
||||
// given text with multiline code blocks
|
||||
const text = `Before
|
||||
\`\`\`javascript
|
||||
/command-inside-code
|
||||
\`\`\`
|
||||
After`
|
||||
|
||||
// #when removing code blocks
|
||||
// when removing code blocks
|
||||
const result = removeCodeBlocks(text)
|
||||
|
||||
// #then code blocks should be removed
|
||||
// then code blocks should be removed
|
||||
expect(result).toContain("Before")
|
||||
expect(result).toContain("After")
|
||||
expect(result).not.toContain("/command-inside-code")
|
||||
})
|
||||
|
||||
it("should handle text without code blocks", () => {
|
||||
// #given text without code blocks
|
||||
// given text without code blocks
|
||||
const text = "Just regular text"
|
||||
|
||||
// #when removing code blocks
|
||||
// when removing code blocks
|
||||
const result = removeCodeBlocks(text)
|
||||
|
||||
// #then text should remain unchanged
|
||||
// then text should remain unchanged
|
||||
expect(result).toBe("Just regular text")
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseSlashCommand", () => {
|
||||
it("should parse simple command without args", () => {
|
||||
// #given a simple slash command
|
||||
// given a simple slash command
|
||||
const text = "/commit"
|
||||
|
||||
// #when parsing
|
||||
// when parsing
|
||||
const result = parseSlashCommand(text)
|
||||
|
||||
// #then should extract command correctly
|
||||
// then should extract command correctly
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.command).toBe("commit")
|
||||
expect(result?.args).toBe("")
|
||||
})
|
||||
|
||||
it("should parse command with arguments", () => {
|
||||
// #given a slash command with arguments
|
||||
// given a slash command with arguments
|
||||
const text = "/plan create a new feature for auth"
|
||||
|
||||
// #when parsing
|
||||
// when parsing
|
||||
const result = parseSlashCommand(text)
|
||||
|
||||
// #then should extract command and args
|
||||
// then should extract command and args
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.command).toBe("plan")
|
||||
expect(result?.args).toBe("create a new feature for auth")
|
||||
})
|
||||
|
||||
it("should parse command with quoted arguments", () => {
|
||||
// #given a slash command with quoted arguments
|
||||
// given a slash command with quoted arguments
|
||||
const text = '/execute "build the API"'
|
||||
|
||||
// #when parsing
|
||||
// when parsing
|
||||
const result = parseSlashCommand(text)
|
||||
|
||||
// #then should extract command and args
|
||||
// then should extract command and args
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.command).toBe("execute")
|
||||
expect(result?.args).toBe('"build the API"')
|
||||
})
|
||||
|
||||
it("should parse command with hyphen in name", () => {
|
||||
// #given a slash command with hyphen
|
||||
// given a slash command with hyphen
|
||||
const text = "/frontend-template-creator project"
|
||||
|
||||
// #when parsing
|
||||
// when parsing
|
||||
const result = parseSlashCommand(text)
|
||||
|
||||
// #then should extract full command name
|
||||
// then should extract full command name
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.command).toBe("frontend-template-creator")
|
||||
expect(result?.args).toBe("project")
|
||||
})
|
||||
|
||||
it("should return null for non-slash text", () => {
|
||||
// #given text without slash
|
||||
const text = "regular text"
|
||||
it("should parse namespaced marketplace commands", () => {
|
||||
// given a namespaced command
|
||||
const text = "/daplug:run-prompt build bridge"
|
||||
|
||||
// #when parsing
|
||||
// when parsing
|
||||
const result = parseSlashCommand(text)
|
||||
|
||||
// #then should return null
|
||||
// then should keep full namespaced command
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.command).toBe("daplug:run-prompt")
|
||||
expect(result?.args).toBe("build bridge")
|
||||
})
|
||||
|
||||
it("should return null for non-slash text", () => {
|
||||
// given text without slash
|
||||
const text = "regular text"
|
||||
|
||||
// when parsing
|
||||
const result = parseSlashCommand(text)
|
||||
|
||||
// then should return null
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for slash not at start", () => {
|
||||
// #given text with slash in middle
|
||||
// given text with slash in middle
|
||||
const text = "some text /command"
|
||||
|
||||
// #when parsing
|
||||
// when parsing
|
||||
const result = parseSlashCommand(text)
|
||||
|
||||
// #then should return null (slash not at start)
|
||||
// then should return null (slash not at start)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for just a slash", () => {
|
||||
// #given just a slash
|
||||
// given just a slash
|
||||
const text = "/"
|
||||
|
||||
// #when parsing
|
||||
// when parsing
|
||||
const result = parseSlashCommand(text)
|
||||
|
||||
// #then should return null
|
||||
// then should return null
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for slash followed by number", () => {
|
||||
// #given slash followed by number
|
||||
// given slash followed by number
|
||||
const text = "/123"
|
||||
|
||||
// #when parsing
|
||||
// when parsing
|
||||
const result = parseSlashCommand(text)
|
||||
|
||||
// #then should return null (command must start with letter)
|
||||
// then should return null (command must start with letter)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should handle whitespace before slash", () => {
|
||||
// #given command with leading whitespace
|
||||
// given command with leading whitespace
|
||||
const text = " /commit"
|
||||
|
||||
// #when parsing
|
||||
// when parsing
|
||||
const result = parseSlashCommand(text)
|
||||
|
||||
// #then should parse after trimming
|
||||
// then should parse after trimming
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.command).toBe("commit")
|
||||
})
|
||||
@@ -161,31 +174,31 @@ After`
|
||||
|
||||
describe("isExcludedCommand", () => {
|
||||
it("should exclude ralph-loop", () => {
|
||||
// #given ralph-loop command
|
||||
// #when checking exclusion
|
||||
// #then should be excluded
|
||||
// given ralph-loop command
|
||||
// when checking exclusion
|
||||
// then should be excluded
|
||||
expect(isExcludedCommand("ralph-loop")).toBe(true)
|
||||
})
|
||||
|
||||
it("should exclude cancel-ralph", () => {
|
||||
// #given cancel-ralph command
|
||||
// #when checking exclusion
|
||||
// #then should be excluded
|
||||
// given cancel-ralph command
|
||||
// when checking exclusion
|
||||
// then should be excluded
|
||||
expect(isExcludedCommand("cancel-ralph")).toBe(true)
|
||||
})
|
||||
|
||||
it("should be case-insensitive for exclusion", () => {
|
||||
// #given uppercase variants
|
||||
// #when checking exclusion
|
||||
// #then should still be excluded
|
||||
// given uppercase variants
|
||||
// when checking exclusion
|
||||
// then should still be excluded
|
||||
expect(isExcludedCommand("RALPH-LOOP")).toBe(true)
|
||||
expect(isExcludedCommand("Cancel-Ralph")).toBe(true)
|
||||
})
|
||||
|
||||
it("should not exclude regular commands", () => {
|
||||
// #given regular commands
|
||||
// #when checking exclusion
|
||||
// #then should not be excluded
|
||||
// given regular commands
|
||||
// when checking exclusion
|
||||
// then should not be excluded
|
||||
expect(isExcludedCommand("commit")).toBe(false)
|
||||
expect(isExcludedCommand("plan")).toBe(false)
|
||||
expect(isExcludedCommand("execute")).toBe(false)
|
||||
@@ -194,102 +207,102 @@ After`
|
||||
|
||||
describe("detectSlashCommand", () => {
|
||||
it("should detect slash command in plain text", () => {
|
||||
// #given plain text with slash command
|
||||
// given plain text with slash command
|
||||
const text = "/commit fix typo"
|
||||
|
||||
// #when detecting
|
||||
// when detecting
|
||||
const result = detectSlashCommand(text)
|
||||
|
||||
// #then should detect
|
||||
// then should detect
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.command).toBe("commit")
|
||||
expect(result?.args).toBe("fix typo")
|
||||
})
|
||||
|
||||
it("should NOT detect slash command inside code block", () => {
|
||||
// #given slash command inside code block
|
||||
// given slash command inside code block
|
||||
const text = "```bash\n/command\n```"
|
||||
|
||||
// #when detecting
|
||||
// when detecting
|
||||
const result = detectSlashCommand(text)
|
||||
|
||||
// #then should not detect (only code block content)
|
||||
// then should not detect (only code block content)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should detect command when text has code blocks elsewhere", () => {
|
||||
// #given slash command before code block
|
||||
// given slash command before code block
|
||||
const text = "/commit fix\n```code```"
|
||||
|
||||
// #when detecting
|
||||
// when detecting
|
||||
const result = detectSlashCommand(text)
|
||||
|
||||
// #then should detect the command
|
||||
// then should detect the command
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.command).toBe("commit")
|
||||
})
|
||||
|
||||
it("should NOT detect excluded commands", () => {
|
||||
// #given excluded command
|
||||
// given excluded command
|
||||
const text = "/ralph-loop do something"
|
||||
|
||||
// #when detecting
|
||||
// when detecting
|
||||
const result = detectSlashCommand(text)
|
||||
|
||||
// #then should not detect
|
||||
// then should not detect
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for non-command text", () => {
|
||||
// #given regular text
|
||||
// given regular text
|
||||
const text = "Just some regular text"
|
||||
|
||||
// #when detecting
|
||||
// when detecting
|
||||
const result = detectSlashCommand(text)
|
||||
|
||||
// #then should return null
|
||||
// then should return null
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("extractPromptText", () => {
|
||||
it("should extract text from parts", () => {
|
||||
// #given message parts
|
||||
// given message parts
|
||||
const parts = [
|
||||
{ type: "text", text: "Hello " },
|
||||
{ type: "tool_use", id: "123" },
|
||||
{ type: "text", text: "world" },
|
||||
]
|
||||
|
||||
// #when extracting
|
||||
// when extracting
|
||||
const result = extractPromptText(parts)
|
||||
|
||||
// #then should join text parts
|
||||
// then should join text parts
|
||||
expect(result).toBe("Hello world")
|
||||
})
|
||||
|
||||
it("should handle empty parts", () => {
|
||||
// #given empty parts
|
||||
// given empty parts
|
||||
const parts: Array<{ type: string; text?: string }> = []
|
||||
|
||||
// #when extracting
|
||||
// when extracting
|
||||
const result = extractPromptText(parts)
|
||||
|
||||
// #then should return empty string
|
||||
// then should return empty string
|
||||
expect(result).toBe("")
|
||||
})
|
||||
|
||||
it("should handle parts without text", () => {
|
||||
// #given parts without text content
|
||||
// given parts without text content
|
||||
const parts = [
|
||||
{ type: "tool_use", id: "123" },
|
||||
{ type: "tool_result", output: "result" },
|
||||
]
|
||||
|
||||
// #when extracting
|
||||
// when extracting
|
||||
const result = extractPromptText(parts)
|
||||
|
||||
// #then should return empty string
|
||||
// then should return empty string
|
||||
expect(result).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -58,8 +58,31 @@ export function detectSlashCommand(text: string): ParsedSlashCommand | null {
|
||||
export function extractPromptText(
|
||||
parts: Array<{ type: string; text?: string }>
|
||||
): string {
|
||||
return parts
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => p.text || "")
|
||||
.join(" ")
|
||||
const textParts = parts.filter((p) => p.type === "text")
|
||||
const slashPart = textParts.find((p) => (p.text ?? "").trim().startsWith("/"))
|
||||
if (slashPart?.text) {
|
||||
return slashPart.text
|
||||
}
|
||||
|
||||
const nonSyntheticParts = textParts.filter(
|
||||
(p) => !(p as { synthetic?: boolean }).synthetic
|
||||
)
|
||||
if (nonSyntheticParts.length > 0) {
|
||||
return nonSyntheticParts.map((p) => p.text || "").join(" ")
|
||||
}
|
||||
|
||||
return textParts.map((p) => p.text || "").join(" ")
|
||||
}
|
||||
|
||||
export function findSlashCommandPartIndex(
|
||||
parts: Array<{ type: string; text?: string }>
|
||||
): number {
|
||||
for (let idx = 0; idx < parts.length; idx += 1) {
|
||||
const part = parts[idx]
|
||||
if (part.type !== "text") continue
|
||||
if ((part.text ?? "").trim().startsWith("/")) {
|
||||
return idx
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it, mock } from "bun:test"
|
||||
import type { LoadedSkill } from "../../features/opencode-skill-loader"
|
||||
|
||||
mock.module("../../shared", () => ({
|
||||
resolveCommandsInText: async (content: string) => content,
|
||||
resolveFileReferencesInText: async (content: string) => content,
|
||||
}))
|
||||
|
||||
mock.module("../../tools/slashcommand", () => ({
|
||||
discoverCommandsSync: () => [
|
||||
{
|
||||
name: "shadowed",
|
||||
metadata: { name: "shadowed", description: "builtin" },
|
||||
content: "builtin template",
|
||||
scope: "builtin",
|
||||
},
|
||||
{
|
||||
name: "shadowed",
|
||||
metadata: { name: "shadowed", description: "project" },
|
||||
content: "project template",
|
||||
scope: "project",
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
mock.module("../../features/opencode-skill-loader", () => ({
|
||||
discoverAllSkills: async (): Promise<LoadedSkill[]> => [],
|
||||
}))
|
||||
|
||||
const { executeSlashCommand } = await import("./executor")
|
||||
|
||||
function createRestrictedSkill(): LoadedSkill {
|
||||
return {
|
||||
name: "restricted-skill",
|
||||
definition: {
|
||||
name: "restricted-skill",
|
||||
description: "restricted",
|
||||
template: "restricted template",
|
||||
agent: "hephaestus",
|
||||
},
|
||||
scope: "user",
|
||||
}
|
||||
}
|
||||
|
||||
describe("executeSlashCommand resolution semantics", () => {
|
||||
it("returns project command when project and builtin names collide", async () => {
|
||||
//#given
|
||||
const parsed = {
|
||||
command: "shadowed",
|
||||
args: "",
|
||||
raw: "/shadowed",
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = await executeSlashCommand(parsed, { skills: [] })
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.replacementText).toContain("**Scope**: project")
|
||||
expect(result.replacementText).toContain("project template")
|
||||
expect(result.replacementText).not.toContain("builtin template")
|
||||
})
|
||||
|
||||
it("blocks slash skill invocation when invoking agent is missing", async () => {
|
||||
//#given
|
||||
const parsed = {
|
||||
command: "restricted-skill",
|
||||
args: "",
|
||||
raw: "/restricted-skill",
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = await executeSlashCommand(parsed, { skills: [createRestrictedSkill()] })
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('Skill "restricted-skill" is restricted to agent "hephaestus"')
|
||||
})
|
||||
|
||||
it("allows slash skill invocation when invoking agent matches restriction", async () => {
|
||||
//#given
|
||||
const parsed = {
|
||||
command: "restricted-skill",
|
||||
args: "",
|
||||
raw: "/restricted-skill",
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = await executeSlashCommand(parsed, {
|
||||
skills: [createRestrictedSkill()],
|
||||
agent: "hephaestus",
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.replacementText).toContain("restricted template")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,195 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { executeSlashCommand } from "./executor"
|
||||
|
||||
const ENV_KEYS = [
|
||||
"CLAUDE_CONFIG_DIR",
|
||||
"CLAUDE_PLUGINS_HOME",
|
||||
"CLAUDE_SETTINGS_PATH",
|
||||
"OPENCODE_CONFIG_DIR",
|
||||
] as const
|
||||
|
||||
type EnvKey = (typeof ENV_KEYS)[number]
|
||||
type EnvSnapshot = Record<EnvKey, string | undefined>
|
||||
|
||||
function writePluginFixture(baseDir: string): void {
|
||||
const claudeConfigDir = join(baseDir, "claude-config")
|
||||
const pluginsHome = join(claudeConfigDir, "plugins")
|
||||
const settingsPath = join(claudeConfigDir, "settings.json")
|
||||
const opencodeConfigDir = join(baseDir, "opencode-config")
|
||||
const pluginInstallPath = join(baseDir, "installed-plugins", "daplug")
|
||||
const pluginKey = "daplug@1.0.0"
|
||||
|
||||
mkdirSync(join(pluginInstallPath, ".claude-plugin"), { recursive: true })
|
||||
mkdirSync(join(pluginInstallPath, "commands"), { recursive: true })
|
||||
|
||||
writeFileSync(
|
||||
join(pluginInstallPath, ".claude-plugin", "plugin.json"),
|
||||
JSON.stringify({ name: "daplug", version: "1.0.0" }, null, 2),
|
||||
)
|
||||
writeFileSync(
|
||||
join(pluginInstallPath, "commands", "run-prompt.md"),
|
||||
`---
|
||||
description: Run prompt from daplug
|
||||
---
|
||||
Execute daplug prompt flow.
|
||||
`,
|
||||
)
|
||||
writeFileSync(
|
||||
join(pluginInstallPath, "commands", "templated.md"),
|
||||
`---
|
||||
description: Templated prompt from daplug
|
||||
---
|
||||
Echo $ARGUMENTS and \${user_message}.
|
||||
`,
|
||||
)
|
||||
|
||||
mkdirSync(pluginsHome, { recursive: true })
|
||||
writeFileSync(
|
||||
join(pluginsHome, "installed_plugins.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 2,
|
||||
plugins: {
|
||||
[pluginKey]: [
|
||||
{
|
||||
scope: "user",
|
||||
installPath: pluginInstallPath,
|
||||
version: "1.0.0",
|
||||
installedAt: "2026-01-01T00:00:00.000Z",
|
||||
lastUpdated: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
mkdirSync(claudeConfigDir, { recursive: true })
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
enabledPlugins: {
|
||||
[pluginKey]: true,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
mkdirSync(opencodeConfigDir, { recursive: true })
|
||||
|
||||
process.env.CLAUDE_CONFIG_DIR = claudeConfigDir
|
||||
process.env.CLAUDE_PLUGINS_HOME = pluginsHome
|
||||
process.env.CLAUDE_SETTINGS_PATH = settingsPath
|
||||
process.env.OPENCODE_CONFIG_DIR = opencodeConfigDir
|
||||
}
|
||||
|
||||
describe("auto-slash command executor plugin dispatch", () => {
|
||||
let tempDir = ""
|
||||
let envSnapshot: EnvSnapshot
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "omo-executor-plugin-test-"))
|
||||
envSnapshot = {
|
||||
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
|
||||
CLAUDE_PLUGINS_HOME: process.env.CLAUDE_PLUGINS_HOME,
|
||||
CLAUDE_SETTINGS_PATH: process.env.CLAUDE_SETTINGS_PATH,
|
||||
OPENCODE_CONFIG_DIR: process.env.OPENCODE_CONFIG_DIR,
|
||||
}
|
||||
writePluginFixture(tempDir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of ENV_KEYS) {
|
||||
const previousValue = envSnapshot[key]
|
||||
if (previousValue === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = previousValue
|
||||
}
|
||||
}
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("resolves marketplace plugin commands when plugin loading is enabled", async () => {
|
||||
const result = await executeSlashCommand(
|
||||
{
|
||||
command: "daplug:run-prompt",
|
||||
args: "ship it",
|
||||
raw: "/daplug:run-prompt ship it",
|
||||
},
|
||||
{
|
||||
skills: [],
|
||||
pluginsEnabled: true,
|
||||
},
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.replacementText).toContain("# /daplug:run-prompt Command")
|
||||
expect(result.replacementText).toContain("**Scope**: plugin")
|
||||
})
|
||||
|
||||
it("excludes marketplace commands when plugins are disabled via config toggle", async () => {
|
||||
const result = await executeSlashCommand(
|
||||
{
|
||||
command: "daplug:run-prompt",
|
||||
args: "",
|
||||
raw: "/daplug:run-prompt",
|
||||
},
|
||||
{
|
||||
skills: [],
|
||||
pluginsEnabled: false,
|
||||
},
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe(
|
||||
'Command "/daplug:run-prompt" not found. Use the skill tool to list available skills and commands.',
|
||||
)
|
||||
})
|
||||
|
||||
it("returns standard not-found for unknown namespaced commands", async () => {
|
||||
const result = await executeSlashCommand(
|
||||
{
|
||||
command: "daplug:missing",
|
||||
args: "",
|
||||
raw: "/daplug:missing",
|
||||
},
|
||||
{
|
||||
skills: [],
|
||||
pluginsEnabled: true,
|
||||
},
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe(
|
||||
'Command "/daplug:missing" not found. Use the skill tool to list available skills and commands.',
|
||||
)
|
||||
expect(result.error).not.toContain("Marketplace plugin commands")
|
||||
})
|
||||
|
||||
it("replaces $ARGUMENTS placeholders in plugin command templates", async () => {
|
||||
const result = await executeSlashCommand(
|
||||
{
|
||||
command: "daplug:templated",
|
||||
args: "ship it",
|
||||
raw: "/daplug:templated ship it",
|
||||
},
|
||||
{
|
||||
skills: [],
|
||||
pluginsEnabled: true,
|
||||
},
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.replacementText).toContain("Echo ship it and ship it.")
|
||||
expect(result.replacementText).not.toContain("$ARGUMENTS")
|
||||
expect(result.replacementText).not.toContain("${user_message}")
|
||||
})
|
||||
})
|
||||
@@ -1,84 +1,25 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "fs"
|
||||
import { join, basename, dirname } from "path"
|
||||
import { dirname } from "path"
|
||||
import {
|
||||
parseFrontmatter,
|
||||
resolveCommandsInText,
|
||||
resolveFileReferencesInText,
|
||||
sanitizeModelField,
|
||||
getClaudeConfigDir,
|
||||
getOpenCodeConfigDir,
|
||||
} from "../../shared"
|
||||
import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types"
|
||||
import { isMarkdownFile } from "../../shared/file-utils"
|
||||
import { discoverAllSkills, type LoadedSkill, type LazyContentLoader } from "../../features/opencode-skill-loader"
|
||||
import { discoverCommandsSync } from "../../tools/slashcommand"
|
||||
import type { CommandInfo as DiscoveredCommandInfo, CommandMetadata } from "../../tools/slashcommand/types"
|
||||
import type { ParsedSlashCommand } from "./types"
|
||||
|
||||
interface CommandScope {
|
||||
type: "user" | "project" | "opencode" | "opencode-project" | "skill"
|
||||
}
|
||||
|
||||
interface CommandMetadata {
|
||||
name: string
|
||||
description: string
|
||||
argumentHint?: string
|
||||
model?: string
|
||||
agent?: string
|
||||
subtask?: boolean
|
||||
}
|
||||
|
||||
interface CommandInfo {
|
||||
interface SkillCommandInfo {
|
||||
name: string
|
||||
path?: string
|
||||
metadata: CommandMetadata
|
||||
content?: string
|
||||
scope: CommandScope["type"]
|
||||
scope: "skill"
|
||||
lazyContentLoader?: LazyContentLoader
|
||||
}
|
||||
|
||||
function discoverCommandsFromDir(commandsDir: string, scope: CommandScope["type"]): CommandInfo[] {
|
||||
if (!existsSync(commandsDir)) {
|
||||
return []
|
||||
}
|
||||
type CommandInfo = DiscoveredCommandInfo | SkillCommandInfo
|
||||
|
||||
const entries = readdirSync(commandsDir, { withFileTypes: true })
|
||||
const commands: CommandInfo[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!isMarkdownFile(entry)) continue
|
||||
|
||||
const commandPath = join(commandsDir, entry.name)
|
||||
const commandName = basename(entry.name, ".md")
|
||||
|
||||
try {
|
||||
const content = readFileSync(commandPath, "utf-8")
|
||||
const { data, body } = parseFrontmatter<CommandFrontmatter>(content)
|
||||
|
||||
const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
|
||||
const metadata: CommandMetadata = {
|
||||
name: commandName,
|
||||
description: data.description || "",
|
||||
argumentHint: data["argument-hint"],
|
||||
model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"),
|
||||
agent: data.agent,
|
||||
subtask: Boolean(data.subtask),
|
||||
}
|
||||
|
||||
commands.push({
|
||||
name: commandName,
|
||||
path: commandPath,
|
||||
metadata,
|
||||
content: body,
|
||||
scope,
|
||||
})
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
|
||||
function skillToCommandInfo(skill: LoadedSkill): CommandInfo {
|
||||
function skillToCommandInfo(skill: LoadedSkill): SkillCommandInfo {
|
||||
return {
|
||||
name: skill.name,
|
||||
path: skill.path,
|
||||
@@ -98,29 +39,33 @@ function skillToCommandInfo(skill: LoadedSkill): CommandInfo {
|
||||
|
||||
export interface ExecutorOptions {
|
||||
skills?: LoadedSkill[]
|
||||
pluginsEnabled?: boolean
|
||||
enabledPluginsOverride?: Record<string, boolean>
|
||||
agent?: string
|
||||
}
|
||||
|
||||
async function discoverAllCommands(options?: ExecutorOptions): Promise<CommandInfo[]> {
|
||||
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
|
||||
const userCommandsDir = join(getClaudeConfigDir(), "commands")
|
||||
const projectCommandsDir = join(process.cwd(), ".claude", "commands")
|
||||
const opencodeGlobalDir = join(configDir, "command")
|
||||
const opencodeProjectDir = join(process.cwd(), ".opencode", "command")
|
||||
|
||||
const userCommands = discoverCommandsFromDir(userCommandsDir, "user")
|
||||
const opencodeGlobalCommands = discoverCommandsFromDir(opencodeGlobalDir, "opencode")
|
||||
const projectCommands = discoverCommandsFromDir(projectCommandsDir, "project")
|
||||
const opencodeProjectCommands = discoverCommandsFromDir(opencodeProjectDir, "opencode-project")
|
||||
async function discoverAllCommands(options?: ExecutorOptions): Promise<CommandInfo[]> {
|
||||
const discoveredCommands = discoverCommandsSync(process.cwd(), {
|
||||
pluginsEnabled: options?.pluginsEnabled,
|
||||
enabledPluginsOverride: options?.enabledPluginsOverride,
|
||||
})
|
||||
|
||||
const skills = options?.skills ?? await discoverAllSkills()
|
||||
const skillCommands = skills.map(skillToCommandInfo)
|
||||
|
||||
const scopeOrder: DiscoveredCommandInfo["scope"][] = ["project", "user", "opencode-project", "opencode", "builtin", "plugin"]
|
||||
const grouped = new Map<string, DiscoveredCommandInfo[]>()
|
||||
for (const cmd of discoveredCommands) {
|
||||
const list = grouped.get(cmd.scope) ?? []
|
||||
list.push(cmd)
|
||||
grouped.set(cmd.scope, list)
|
||||
}
|
||||
const orderedCommands = scopeOrder.flatMap((scope) => grouped.get(scope) ?? [])
|
||||
|
||||
return [
|
||||
...opencodeProjectCommands,
|
||||
...projectCommands,
|
||||
...opencodeGlobalCommands,
|
||||
...userCommands,
|
||||
...skillCommands,
|
||||
...orderedCommands,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -164,7 +109,11 @@ async function formatCommandTemplate(cmd: CommandInfo, args: string): Promise<st
|
||||
const commandDir = cmd.path ? dirname(cmd.path) : process.cwd()
|
||||
const withFileRefs = await resolveFileReferencesInText(content, commandDir)
|
||||
const resolvedContent = await resolveCommandsInText(withFileRefs)
|
||||
sections.push(resolvedContent.trim())
|
||||
const resolvedArguments = args
|
||||
const substitutedContent = resolvedContent
|
||||
.replace(/\$\{user_message\}/g, resolvedArguments)
|
||||
.replace(/\$ARGUMENTS/g, resolvedArguments)
|
||||
sections.push(substitutedContent.trim())
|
||||
|
||||
if (args) {
|
||||
sections.push("\n\n---\n")
|
||||
@@ -187,7 +136,16 @@ export async function executeSlashCommand(parsed: ParsedSlashCommand, options?:
|
||||
if (!command) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Command "/${parsed.command}" not found. Use the slashcommand tool to list available commands.`,
|
||||
error: `Command "/${parsed.command}" not found. Use the skill tool to list available skills and commands.`,
|
||||
}
|
||||
}
|
||||
|
||||
if (command.scope === "skill" && command.metadata.agent) {
|
||||
if (!options?.agent || command.metadata.agent !== options.agent) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Skill "${command.name}" is restricted to agent "${command.metadata.agent}"`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import {
|
||||
detectSlashCommand,
|
||||
extractPromptText,
|
||||
findSlashCommandPartIndex,
|
||||
} from "./detector"
|
||||
import { executeSlashCommand, type ExecutorOptions } from "./executor"
|
||||
import { log } from "../../shared"
|
||||
import {
|
||||
AUTO_SLASH_COMMAND_TAG_CLOSE,
|
||||
AUTO_SLASH_COMMAND_TAG_OPEN,
|
||||
} from "./constants"
|
||||
import { createProcessedCommandStore } from "./processed-command-store"
|
||||
import type {
|
||||
AutoSlashCommandHookInput,
|
||||
AutoSlashCommandHookOutput,
|
||||
CommandExecuteBeforeInput,
|
||||
CommandExecuteBeforeOutput,
|
||||
} from "./types"
|
||||
import type { LoadedSkill } from "../../features/opencode-skill-loader"
|
||||
|
||||
const COMMAND_EXECUTE_FALLBACK_DEDUP_TTL_MS = 100
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
function getDeletedSessionID(properties: unknown): string | null {
|
||||
if (!isRecord(properties)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const info = properties.info
|
||||
if (!isRecord(info)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return typeof info.id === "string" ? info.id : null
|
||||
}
|
||||
|
||||
function getCommandExecutionEventID(input: CommandExecuteBeforeInput): string | null {
|
||||
const candidateKeys = [
|
||||
"messageID",
|
||||
"messageId",
|
||||
"eventID",
|
||||
"eventId",
|
||||
"invocationID",
|
||||
"invocationId",
|
||||
"commandID",
|
||||
"commandId",
|
||||
]
|
||||
|
||||
const recordInput = input as unknown
|
||||
if (!isRecord(recordInput)) {
|
||||
return null
|
||||
}
|
||||
|
||||
for (const key of candidateKeys) {
|
||||
const candidateValue = recordInput[key]
|
||||
if (typeof candidateValue === "string" && candidateValue.length > 0) {
|
||||
return candidateValue
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export interface AutoSlashCommandHookOptions {
|
||||
skills?: LoadedSkill[]
|
||||
pluginsEnabled?: boolean
|
||||
enabledPluginsOverride?: Record<string, boolean>
|
||||
}
|
||||
|
||||
export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions) {
|
||||
const executorOptions: ExecutorOptions = {
|
||||
skills: options?.skills,
|
||||
pluginsEnabled: options?.pluginsEnabled,
|
||||
enabledPluginsOverride: options?.enabledPluginsOverride,
|
||||
}
|
||||
const sessionProcessedCommands = createProcessedCommandStore()
|
||||
const sessionProcessedCommandExecutions = createProcessedCommandStore()
|
||||
|
||||
const dispose = (): void => {
|
||||
sessionProcessedCommands.clear()
|
||||
sessionProcessedCommandExecutions.clear()
|
||||
}
|
||||
|
||||
return {
|
||||
"chat.message": async (
|
||||
input: AutoSlashCommandHookInput,
|
||||
output: AutoSlashCommandHookOutput
|
||||
): Promise<void> => {
|
||||
const promptText = extractPromptText(output.parts)
|
||||
|
||||
// Debug logging to diagnose slash command issues
|
||||
if (promptText.startsWith("/")) {
|
||||
log(`[auto-slash-command] chat.message hook received slash command`, {
|
||||
sessionID: input.sessionID,
|
||||
promptText: promptText.slice(0, 100),
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
promptText.includes(AUTO_SLASH_COMMAND_TAG_OPEN) ||
|
||||
promptText.includes(AUTO_SLASH_COMMAND_TAG_CLOSE)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const parsed = detectSlashCommand(promptText)
|
||||
|
||||
if (!parsed) {
|
||||
return
|
||||
}
|
||||
|
||||
const commandKey = input.messageID
|
||||
? `${input.sessionID}:${input.messageID}:${parsed.command}`
|
||||
: `${input.sessionID}:${parsed.command}`
|
||||
if (sessionProcessedCommands.has(commandKey)) {
|
||||
return
|
||||
}
|
||||
sessionProcessedCommands.add(commandKey)
|
||||
|
||||
log(`[auto-slash-command] Detected: /${parsed.command}`, {
|
||||
sessionID: input.sessionID,
|
||||
args: parsed.args,
|
||||
})
|
||||
|
||||
const executionOptions: ExecutorOptions = {
|
||||
...executorOptions,
|
||||
agent: input.agent,
|
||||
}
|
||||
|
||||
const result = await executeSlashCommand(parsed, executionOptions)
|
||||
|
||||
const idx = findSlashCommandPartIndex(output.parts)
|
||||
if (idx < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!result.success || !result.replacementText) {
|
||||
log(`[auto-slash-command] Command not found, skipping`, {
|
||||
sessionID: input.sessionID,
|
||||
command: parsed.command,
|
||||
error: result.error,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const taggedContent = `${AUTO_SLASH_COMMAND_TAG_OPEN}\n${result.replacementText}\n${AUTO_SLASH_COMMAND_TAG_CLOSE}`
|
||||
output.parts[idx].text = taggedContent
|
||||
|
||||
log(`[auto-slash-command] Replaced message with command template`, {
|
||||
sessionID: input.sessionID,
|
||||
command: parsed.command,
|
||||
})
|
||||
},
|
||||
|
||||
"command.execute.before": async (
|
||||
input: CommandExecuteBeforeInput,
|
||||
output: CommandExecuteBeforeOutput
|
||||
): Promise<void> => {
|
||||
const eventID = getCommandExecutionEventID(input)
|
||||
const commandKey = eventID
|
||||
? `${input.sessionID}:event:${eventID}`
|
||||
: `${input.sessionID}:fallback:${input.command.toLowerCase()}:${input.arguments || ""}`
|
||||
if (sessionProcessedCommandExecutions.has(commandKey)) {
|
||||
return
|
||||
}
|
||||
|
||||
log(`[auto-slash-command] command.execute.before received`, {
|
||||
sessionID: input.sessionID,
|
||||
command: input.command,
|
||||
arguments: input.arguments,
|
||||
})
|
||||
|
||||
const parsed = {
|
||||
command: input.command,
|
||||
args: input.arguments || "",
|
||||
raw: `/${input.command}${input.arguments ? " " + input.arguments : ""}`,
|
||||
}
|
||||
|
||||
const executionOptions: ExecutorOptions = {
|
||||
...executorOptions,
|
||||
agent: input.agent,
|
||||
}
|
||||
|
||||
const result = await executeSlashCommand(parsed, executionOptions)
|
||||
|
||||
if (!result.success || !result.replacementText) {
|
||||
log(`[auto-slash-command] command.execute.before - command not found in our executor`, {
|
||||
sessionID: input.sessionID,
|
||||
command: input.command,
|
||||
error: result.error,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
sessionProcessedCommandExecutions.add(
|
||||
commandKey,
|
||||
eventID ? undefined : COMMAND_EXECUTE_FALLBACK_DEDUP_TTL_MS
|
||||
)
|
||||
|
||||
const taggedContent = `${AUTO_SLASH_COMMAND_TAG_OPEN}\n${result.replacementText}\n${AUTO_SLASH_COMMAND_TAG_CLOSE}`
|
||||
|
||||
const idx = findSlashCommandPartIndex(output.parts)
|
||||
if (idx >= 0) {
|
||||
output.parts[idx].text = taggedContent
|
||||
} else {
|
||||
output.parts.unshift({ type: "text", text: taggedContent })
|
||||
}
|
||||
|
||||
log(`[auto-slash-command] command.execute.before - injected template`, {
|
||||
sessionID: input.sessionID,
|
||||
command: input.command,
|
||||
})
|
||||
},
|
||||
event: async ({
|
||||
event,
|
||||
}: {
|
||||
event: { type: string; properties?: unknown }
|
||||
}): Promise<void> => {
|
||||
if (event.type !== "session.deleted") {
|
||||
return
|
||||
}
|
||||
|
||||
const sessionID = getDeletedSessionID(event.properties)
|
||||
if (!sessionID) {
|
||||
return
|
||||
}
|
||||
|
||||
sessionProcessedCommands.cleanupSession(sessionID)
|
||||
sessionProcessedCommandExecutions.cleanupSession(sessionID)
|
||||
},
|
||||
dispose,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, expect, it, beforeEach, mock, spyOn } from "bun:test"
|
||||
import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
|
||||
import type {
|
||||
AutoSlashCommandHookInput,
|
||||
AutoSlashCommandHookOutput,
|
||||
CommandExecuteBeforeInput,
|
||||
CommandExecuteBeforeOutput,
|
||||
} from "./types"
|
||||
|
||||
// Import real shared module to avoid mock leaking to other test files
|
||||
@@ -19,7 +22,7 @@ function createMockInput(sessionID: string, messageID?: string): AutoSlashComman
|
||||
sessionID,
|
||||
messageID: messageID ?? `msg-${Date.now()}-${Math.random()}`,
|
||||
agent: "test-agent",
|
||||
model: { providerID: "anthropic", modelID: "claude-sonnet-4-5" },
|
||||
model: { providerID: "anthropic", modelID: "claude-sonnet-4-6" },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +30,7 @@ function createMockOutput(text: string): AutoSlashCommandHookOutput {
|
||||
return {
|
||||
message: {
|
||||
agent: "test-agent",
|
||||
model: { providerID: "anthropic", modelID: "claude-sonnet-4-5" },
|
||||
model: { providerID: "anthropic", modelID: "claude-sonnet-4-6" },
|
||||
path: { cwd: "/test", root: "/test" },
|
||||
tools: {},
|
||||
},
|
||||
@@ -42,118 +45,118 @@ describe("createAutoSlashCommandHook", () => {
|
||||
|
||||
describe("slash command replacement", () => {
|
||||
it("should not modify message when command not found", async () => {
|
||||
// #given a slash command that doesn't exist
|
||||
// given a slash command that doesn't exist
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-notfound-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output = createMockOutput("/nonexistent-command args")
|
||||
const originalText = output.parts[0].text
|
||||
|
||||
// #when hook is called
|
||||
// when hook is called
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then should NOT modify the message (feature inactive when command not found)
|
||||
// then should NOT modify the message (feature inactive when command not found)
|
||||
expect(output.parts[0].text).toBe(originalText)
|
||||
})
|
||||
|
||||
it("should not modify message for unknown command (feature inactive)", async () => {
|
||||
// #given unknown slash command
|
||||
// given unknown slash command
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-tags-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output = createMockOutput("/some-command")
|
||||
const originalText = output.parts[0].text
|
||||
|
||||
// #when hook is called
|
||||
// when hook is called
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then should NOT modify (command not found = feature inactive)
|
||||
// then should NOT modify (command not found = feature inactive)
|
||||
expect(output.parts[0].text).toBe(originalText)
|
||||
})
|
||||
|
||||
it("should not modify for unknown command (no prepending)", async () => {
|
||||
// #given unknown slash command
|
||||
// given unknown slash command
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-replace-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output = createMockOutput("/test-cmd some args")
|
||||
const originalText = output.parts[0].text
|
||||
|
||||
// #when hook is called
|
||||
// when hook is called
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then should not modify (feature inactive for unknown commands)
|
||||
// then should not modify (feature inactive for unknown commands)
|
||||
expect(output.parts[0].text).toBe(originalText)
|
||||
})
|
||||
})
|
||||
|
||||
describe("no slash command", () => {
|
||||
it("should do nothing for regular text", async () => {
|
||||
// #given regular text without slash
|
||||
// given regular text without slash
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-regular-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output = createMockOutput("Just regular text")
|
||||
const originalText = output.parts[0].text
|
||||
|
||||
// #when hook is called
|
||||
// when hook is called
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then should not modify
|
||||
// then should not modify
|
||||
expect(output.parts[0].text).toBe(originalText)
|
||||
})
|
||||
|
||||
it("should do nothing for slash in middle of text", async () => {
|
||||
// #given slash in middle
|
||||
// given slash in middle
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-middle-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output = createMockOutput("Please run /commit later")
|
||||
const originalText = output.parts[0].text
|
||||
|
||||
// #when hook is called
|
||||
// when hook is called
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then should not detect (not at start)
|
||||
// then should not detect (not at start)
|
||||
expect(output.parts[0].text).toBe(originalText)
|
||||
})
|
||||
})
|
||||
|
||||
describe("excluded commands", () => {
|
||||
it("should NOT trigger for ralph-loop command", async () => {
|
||||
// #given ralph-loop command
|
||||
// given ralph-loop command
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-ralph-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output = createMockOutput("/ralph-loop do something")
|
||||
const originalText = output.parts[0].text
|
||||
|
||||
// #when hook is called
|
||||
// when hook is called
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then should not modify (excluded command)
|
||||
// then should not modify (excluded command)
|
||||
expect(output.parts[0].text).toBe(originalText)
|
||||
})
|
||||
|
||||
it("should NOT trigger for cancel-ralph command", async () => {
|
||||
// #given cancel-ralph command
|
||||
// given cancel-ralph command
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-cancel-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output = createMockOutput("/cancel-ralph")
|
||||
const originalText = output.parts[0].text
|
||||
|
||||
// #when hook is called
|
||||
// when hook is called
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then should not modify
|
||||
// then should not modify
|
||||
expect(output.parts[0].text).toBe(originalText)
|
||||
})
|
||||
})
|
||||
|
||||
describe("already processed", () => {
|
||||
it("should skip if auto-slash-command tags already present", async () => {
|
||||
// #given text with existing tags
|
||||
// given text with existing tags
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-existing-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
@@ -162,76 +165,76 @@ describe("createAutoSlashCommandHook", () => {
|
||||
)
|
||||
const originalText = output.parts[0].text
|
||||
|
||||
// #when hook is called
|
||||
// when hook is called
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then should not modify
|
||||
// then should not modify
|
||||
expect(output.parts[0].text).toBe(originalText)
|
||||
})
|
||||
})
|
||||
|
||||
describe("code blocks", () => {
|
||||
it("should NOT detect command inside code block", async () => {
|
||||
// #given command inside code block
|
||||
// given command inside code block
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-codeblock-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output = createMockOutput("```\n/commit\n```")
|
||||
const originalText = output.parts[0].text
|
||||
|
||||
// #when hook is called
|
||||
// when hook is called
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then should not detect
|
||||
// then should not detect
|
||||
expect(output.parts[0].text).toBe(originalText)
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle empty text", async () => {
|
||||
// #given empty text
|
||||
// given empty text
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-empty-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output = createMockOutput("")
|
||||
|
||||
// #when hook is called
|
||||
// #then should not throw
|
||||
// when hook is called
|
||||
// then should not throw
|
||||
await expect(hook["chat.message"](input, output)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it("should handle just slash", async () => {
|
||||
// #given just slash
|
||||
// given just slash
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-slash-only-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output = createMockOutput("/")
|
||||
const originalText = output.parts[0].text
|
||||
|
||||
// #when hook is called
|
||||
// when hook is called
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then should not modify
|
||||
// then should not modify
|
||||
expect(output.parts[0].text).toBe(originalText)
|
||||
})
|
||||
|
||||
it("should handle command with special characters in args (not found = no modification)", async () => {
|
||||
// #given command with special characters that doesn't exist
|
||||
// given command with special characters that doesn't exist
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-special-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output = createMockOutput('/execute "test & stuff <tag>"')
|
||||
const originalText = output.parts[0].text
|
||||
|
||||
// #when hook is called
|
||||
// when hook is called
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then should not modify (command not found = feature inactive)
|
||||
// then should not modify (command not found = feature inactive)
|
||||
expect(output.parts[0].text).toBe(originalText)
|
||||
})
|
||||
|
||||
it("should handle multiple text parts (unknown command = no modification)", async () => {
|
||||
// #given multiple text parts with unknown command
|
||||
// given multiple text parts with unknown command
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-multi-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
@@ -244,11 +247,171 @@ describe("createAutoSlashCommandHook", () => {
|
||||
}
|
||||
const originalText = output.parts[0].text
|
||||
|
||||
// #when hook is called
|
||||
// when hook is called
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then should not modify (command not found = feature inactive)
|
||||
// then should not modify (command not found = feature inactive)
|
||||
expect(output.parts[0].text).toBe(originalText)
|
||||
})
|
||||
})
|
||||
|
||||
describe("command.execute.before hook", () => {
|
||||
function createCommandInput(command: string, args: string = ""): CommandExecuteBeforeInput {
|
||||
return {
|
||||
command,
|
||||
sessionID: `test-session-cmd-${Date.now()}-${Math.random()}`,
|
||||
arguments: args,
|
||||
}
|
||||
}
|
||||
|
||||
function createCommandOutput(text?: string): CommandExecuteBeforeOutput {
|
||||
return {
|
||||
parts: text ? [{ type: "text", text }] : [],
|
||||
}
|
||||
}
|
||||
|
||||
it("should not modify output for unknown command", async () => {
|
||||
//#given
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const input = createCommandInput("nonexistent-command-xyz")
|
||||
const output = createCommandOutput("original text")
|
||||
const originalText = output.parts[0].text
|
||||
|
||||
//#when
|
||||
await hook["command.execute.before"](input, output)
|
||||
|
||||
//#then
|
||||
expect(output.parts[0].text).toBe(originalText)
|
||||
})
|
||||
|
||||
it("should add text part when parts array is empty and command is unknown", async () => {
|
||||
//#given
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const input = createCommandInput("nonexistent-command-abc")
|
||||
const output = createCommandOutput()
|
||||
|
||||
//#when
|
||||
await hook["command.execute.before"](input, output)
|
||||
|
||||
//#then
|
||||
expect(output.parts.length).toBe(0)
|
||||
})
|
||||
|
||||
it("should inject template for known builtin commands like ralph-loop", async () => {
|
||||
//#given
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const input = createCommandInput("ralph-loop")
|
||||
const output = createCommandOutput("original")
|
||||
|
||||
//#when
|
||||
await hook["command.execute.before"](input, output)
|
||||
|
||||
//#then
|
||||
expect(output.parts[0].text).toContain("<auto-slash-command>")
|
||||
expect(output.parts[0].text).toContain("/ralph-loop Command")
|
||||
})
|
||||
|
||||
it("should pass command arguments correctly", async () => {
|
||||
//#given
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const input = createCommandInput("some-command", "arg1 arg2 arg3")
|
||||
const output = createCommandOutput("original")
|
||||
|
||||
//#when
|
||||
await hook["command.execute.before"](input, output)
|
||||
|
||||
//#then
|
||||
expect(logMock).toHaveBeenCalledWith(
|
||||
"[auto-slash-command] command.execute.before received",
|
||||
expect.objectContaining({
|
||||
command: "some-command",
|
||||
arguments: "arg1 arg2 arg3",
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
})
|
||||
describe("skills as slash commands", () => {
|
||||
function createTestSkill(name: string, template: string): LoadedSkill {
|
||||
return {
|
||||
name,
|
||||
path: `/test/skills/${name}/SKILL.md`,
|
||||
definition: {
|
||||
name,
|
||||
description: `Test skill: ${name}`,
|
||||
template,
|
||||
},
|
||||
scope: "user",
|
||||
}
|
||||
}
|
||||
|
||||
it("should replace message with skill template when skill is used as slash command via chat.message", async () => {
|
||||
// given a hook with a skill
|
||||
const skill = createTestSkill("my-test-skill", "This is the skill template content")
|
||||
const hook = createAutoSlashCommandHook({ skills: [skill] })
|
||||
const sessionID = `test-session-skill-chat-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output = createMockOutput("/my-test-skill some arguments")
|
||||
|
||||
// when hook processes the message
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// then should replace message with skill template
|
||||
expect(output.parts[0].text).toContain("<auto-slash-command>")
|
||||
expect(output.parts[0].text).toContain("/my-test-skill Command")
|
||||
expect(output.parts[0].text).toContain("This is the skill template content")
|
||||
})
|
||||
|
||||
it("should inject skill template via command.execute.before", async () => {
|
||||
// given a hook with a skill
|
||||
const skill = createTestSkill("my-test-skill", "Skill template for command execute")
|
||||
const hook = createAutoSlashCommandHook({ skills: [skill] })
|
||||
const input: CommandExecuteBeforeInput = {
|
||||
command: "my-test-skill",
|
||||
sessionID: `test-session-skill-cmd-${Date.now()}-${Math.random()}`,
|
||||
arguments: "extra args",
|
||||
}
|
||||
const output: CommandExecuteBeforeOutput = {
|
||||
parts: [{ type: "text", text: "original" }],
|
||||
}
|
||||
|
||||
// when hook processes the command
|
||||
await hook["command.execute.before"](input, output)
|
||||
|
||||
// then should inject skill template
|
||||
expect(output.parts[0].text).toContain("<auto-slash-command>")
|
||||
expect(output.parts[0].text).toContain("/my-test-skill Command")
|
||||
expect(output.parts[0].text).toContain("Skill template for command execute")
|
||||
expect(output.parts[0].text).toContain("extra args")
|
||||
})
|
||||
|
||||
it("should handle skill with lazy content loader", async () => {
|
||||
// given a skill with lazy content (no inline template)
|
||||
const skill: LoadedSkill = {
|
||||
name: "lazy-skill",
|
||||
path: "/test/skills/lazy-skill/SKILL.md",
|
||||
definition: {
|
||||
name: "lazy-skill",
|
||||
description: "A lazy-loaded skill",
|
||||
template: "",
|
||||
},
|
||||
scope: "user",
|
||||
lazyContent: {
|
||||
loaded: false,
|
||||
load: async () => "Lazy loaded skill content here",
|
||||
},
|
||||
}
|
||||
const hook = createAutoSlashCommandHook({ skills: [skill] })
|
||||
const sessionID = `test-session-lazy-skill-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output = createMockOutput("/lazy-skill")
|
||||
|
||||
// when hook processes the message
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// then should replace message with lazily loaded content
|
||||
expect(output.parts[0].text).toContain("<auto-slash-command>")
|
||||
expect(output.parts[0].text).toContain("Lazy loaded skill content here")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,89 +1,7 @@
|
||||
import {
|
||||
detectSlashCommand,
|
||||
extractPromptText,
|
||||
} from "./detector"
|
||||
import { executeSlashCommand, type ExecutorOptions } from "./executor"
|
||||
import { log } from "../../shared"
|
||||
import {
|
||||
AUTO_SLASH_COMMAND_TAG_OPEN,
|
||||
AUTO_SLASH_COMMAND_TAG_CLOSE,
|
||||
} from "./constants"
|
||||
import type {
|
||||
AutoSlashCommandHookInput,
|
||||
AutoSlashCommandHookOutput,
|
||||
} from "./types"
|
||||
import type { LoadedSkill } from "../../features/opencode-skill-loader"
|
||||
|
||||
export * from "./detector"
|
||||
export * from "./executor"
|
||||
export * from "./constants"
|
||||
export * from "./types"
|
||||
|
||||
const sessionProcessedCommands = new Set<string>()
|
||||
|
||||
export interface AutoSlashCommandHookOptions {
|
||||
skills?: LoadedSkill[]
|
||||
}
|
||||
|
||||
export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions) {
|
||||
const executorOptions: ExecutorOptions = {
|
||||
skills: options?.skills,
|
||||
}
|
||||
|
||||
return {
|
||||
"chat.message": async (
|
||||
input: AutoSlashCommandHookInput,
|
||||
output: AutoSlashCommandHookOutput
|
||||
): Promise<void> => {
|
||||
const promptText = extractPromptText(output.parts)
|
||||
|
||||
if (
|
||||
promptText.includes(AUTO_SLASH_COMMAND_TAG_OPEN) ||
|
||||
promptText.includes(AUTO_SLASH_COMMAND_TAG_CLOSE)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const parsed = detectSlashCommand(promptText)
|
||||
|
||||
if (!parsed) {
|
||||
return
|
||||
}
|
||||
|
||||
const commandKey = `${input.sessionID}:${input.messageID}:${parsed.command}`
|
||||
if (sessionProcessedCommands.has(commandKey)) {
|
||||
return
|
||||
}
|
||||
sessionProcessedCommands.add(commandKey)
|
||||
|
||||
log(`[auto-slash-command] Detected: /${parsed.command}`, {
|
||||
sessionID: input.sessionID,
|
||||
args: parsed.args,
|
||||
})
|
||||
|
||||
const result = await executeSlashCommand(parsed, executorOptions)
|
||||
|
||||
const idx = output.parts.findIndex((p) => p.type === "text" && p.text)
|
||||
if (idx < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!result.success || !result.replacementText) {
|
||||
log(`[auto-slash-command] Command not found, skipping`, {
|
||||
sessionID: input.sessionID,
|
||||
command: parsed.command,
|
||||
error: result.error,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const taggedContent = `${AUTO_SLASH_COMMAND_TAG_OPEN}\n${result.replacementText}\n${AUTO_SLASH_COMMAND_TAG_CLOSE}`
|
||||
output.parts[idx].text = taggedContent
|
||||
|
||||
log(`[auto-slash-command] Replaced message with command template`, {
|
||||
sessionID: input.sessionID,
|
||||
command: parsed.command,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
export { createAutoSlashCommandHook } from "./hook"
|
||||
export type { AutoSlashCommandHookOptions } from "./hook"
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
const MAX_PROCESSED_ENTRY_COUNT = 10_000
|
||||
const PROCESSED_COMMAND_TTL_MS = 30_000
|
||||
|
||||
function pruneExpiredEntries(entries: Map<string, number>, now: number): Map<string, number> {
|
||||
return new Map(Array.from(entries.entries()).filter(([, expiresAt]) => expiresAt > now))
|
||||
}
|
||||
|
||||
function trimProcessedEntries(entries: Map<string, number>): Map<string, number> {
|
||||
if (entries.size <= MAX_PROCESSED_ENTRY_COUNT) {
|
||||
return entries
|
||||
}
|
||||
|
||||
return new Map(
|
||||
Array.from(entries.entries())
|
||||
.sort((left, right) => left[1] - right[1])
|
||||
.slice(Math.floor(entries.size / 2))
|
||||
)
|
||||
}
|
||||
|
||||
function removeSessionEntries(entries: Map<string, number>, sessionID: string): Map<string, number> {
|
||||
const sessionPrefix = `${sessionID}:`
|
||||
return new Map(Array.from(entries.entries()).filter(([entry]) => !entry.startsWith(sessionPrefix)))
|
||||
}
|
||||
|
||||
export interface ProcessedCommandStore {
|
||||
has(commandKey: string): boolean
|
||||
add(commandKey: string, ttlMs?: number): void
|
||||
cleanupSession(sessionID: string): void
|
||||
clear(): void
|
||||
}
|
||||
|
||||
export function createProcessedCommandStore(): ProcessedCommandStore {
|
||||
let entries = new Map<string, number>()
|
||||
|
||||
return {
|
||||
has(commandKey: string): boolean {
|
||||
const now = Date.now()
|
||||
entries = pruneExpiredEntries(entries, now)
|
||||
return entries.has(commandKey)
|
||||
},
|
||||
add(commandKey: string, ttlMs = PROCESSED_COMMAND_TTL_MS): void {
|
||||
const now = Date.now()
|
||||
entries = pruneExpiredEntries(entries, now)
|
||||
entries.delete(commandKey)
|
||||
entries.set(commandKey, now + ttlMs)
|
||||
entries = trimProcessedEntries(entries)
|
||||
},
|
||||
cleanupSession(sessionID: string): void {
|
||||
entries = removeSessionEntries(entries, sessionID)
|
||||
},
|
||||
clear(): void {
|
||||
entries.clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -21,3 +21,22 @@ export interface AutoSlashCommandResult {
|
||||
parsedCommand?: ParsedSlashCommand
|
||||
injectedMessage?: string
|
||||
}
|
||||
|
||||
export interface CommandExecuteBeforeInput {
|
||||
command: string
|
||||
sessionID: string
|
||||
arguments: string
|
||||
agent?: string
|
||||
messageID?: string
|
||||
messageId?: string
|
||||
eventID?: string
|
||||
eventId?: string
|
||||
invocationID?: string
|
||||
invocationId?: string
|
||||
commandID?: string
|
||||
commandId?: string
|
||||
}
|
||||
|
||||
export interface CommandExecuteBeforeOutput {
|
||||
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
|
||||
const TEST_CACHE_DIR = join(import.meta.dir, "__test-cache__")
|
||||
const TEST_OPENCODE_CACHE_DIR = join(TEST_CACHE_DIR, "opencode")
|
||||
const TEST_USER_CONFIG_DIR = "/tmp/opencode-config"
|
||||
|
||||
mock.module("./constants", () => ({
|
||||
CACHE_DIR: TEST_OPENCODE_CACHE_DIR,
|
||||
USER_CONFIG_DIR: TEST_USER_CONFIG_DIR,
|
||||
PACKAGE_NAME: "oh-my-opencode",
|
||||
}))
|
||||
|
||||
mock.module("../../shared/logger", () => ({
|
||||
log: () => {},
|
||||
}))
|
||||
|
||||
function resetTestCache(): void {
|
||||
if (existsSync(TEST_CACHE_DIR)) {
|
||||
rmSync(TEST_CACHE_DIR, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
mkdirSync(join(TEST_OPENCODE_CACHE_DIR, "node_modules", "oh-my-opencode"), { recursive: true })
|
||||
writeFileSync(
|
||||
join(TEST_OPENCODE_CACHE_DIR, "package.json"),
|
||||
JSON.stringify({ dependencies: { "oh-my-opencode": "latest", other: "1.0.0" } }, null, 2)
|
||||
)
|
||||
writeFileSync(
|
||||
join(TEST_OPENCODE_CACHE_DIR, "bun.lock"),
|
||||
JSON.stringify(
|
||||
{
|
||||
workspaces: {
|
||||
"": {
|
||||
dependencies: { "oh-my-opencode": "latest", other: "1.0.0" },
|
||||
},
|
||||
},
|
||||
packages: {
|
||||
"oh-my-opencode": {},
|
||||
other: {},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
writeFileSync(
|
||||
join(TEST_OPENCODE_CACHE_DIR, "node_modules", "oh-my-opencode", "package.json"),
|
||||
'{"name":"oh-my-opencode"}'
|
||||
)
|
||||
}
|
||||
|
||||
describe("invalidatePackage", () => {
|
||||
beforeEach(() => {
|
||||
resetTestCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(TEST_CACHE_DIR)) {
|
||||
rmSync(TEST_CACHE_DIR, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("invalidates the installed package from the OpenCode cache directory", async () => {
|
||||
const { invalidatePackage } = await import("./cache")
|
||||
|
||||
const result = invalidatePackage()
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(existsSync(join(TEST_OPENCODE_CACHE_DIR, "node_modules", "oh-my-opencode"))).toBe(false)
|
||||
|
||||
const packageJson = JSON.parse(readFileSync(join(TEST_OPENCODE_CACHE_DIR, "package.json"), "utf-8")) as {
|
||||
dependencies?: Record<string, string>
|
||||
}
|
||||
expect(packageJson.dependencies?.["oh-my-opencode"]).toBe("latest")
|
||||
expect(packageJson.dependencies?.other).toBe("1.0.0")
|
||||
|
||||
const bunLock = JSON.parse(readFileSync(join(TEST_OPENCODE_CACHE_DIR, "bun.lock"), "utf-8")) as {
|
||||
workspaces?: { ""?: { dependencies?: Record<string, string> } }
|
||||
packages?: Record<string, unknown>
|
||||
}
|
||||
expect(bunLock.workspaces?.[""]?.dependencies?.["oh-my-opencode"]).toBe("latest")
|
||||
expect(bunLock.workspaces?.[""]?.dependencies?.other).toBe("1.0.0")
|
||||
expect(bunLock.packages?.["oh-my-opencode"]).toBeUndefined()
|
||||
expect(bunLock.packages?.other).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import { CACHE_DIR, PACKAGE_NAME } from "./constants"
|
||||
import { CACHE_DIR, PACKAGE_NAME, USER_CONFIG_DIR } from "./constants"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
interface BunLockfile {
|
||||
@@ -16,65 +16,70 @@ function stripTrailingCommas(json: string): string {
|
||||
return json.replace(/,(\s*[}\]])/g, "$1")
|
||||
}
|
||||
|
||||
function removeFromBunLock(packageName: string): boolean {
|
||||
const lockPath = path.join(CACHE_DIR, "bun.lock")
|
||||
if (!fs.existsSync(lockPath)) return false
|
||||
|
||||
function removeFromTextBunLock(lockPath: string, packageName: string): boolean {
|
||||
try {
|
||||
const content = fs.readFileSync(lockPath, "utf-8")
|
||||
const lock = JSON.parse(stripTrailingCommas(content)) as BunLockfile
|
||||
let modified = false
|
||||
|
||||
if (lock.workspaces?.[""]?.dependencies?.[packageName]) {
|
||||
delete lock.workspaces[""].dependencies[packageName]
|
||||
modified = true
|
||||
}
|
||||
|
||||
if (lock.packages?.[packageName]) {
|
||||
delete lock.packages[packageName]
|
||||
modified = true
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2))
|
||||
log(`[auto-update-checker] Removed from bun.lock: ${packageName}`)
|
||||
return true
|
||||
}
|
||||
|
||||
return modified
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function deleteBinaryBunLock(lockPath: string): boolean {
|
||||
try {
|
||||
fs.unlinkSync(lockPath)
|
||||
log(`[auto-update-checker] Removed bun.lockb to force re-resolution`)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function removeFromBunLock(packageName: string): boolean {
|
||||
const textLockPath = path.join(CACHE_DIR, "bun.lock")
|
||||
const binaryLockPath = path.join(CACHE_DIR, "bun.lockb")
|
||||
|
||||
if (fs.existsSync(textLockPath)) {
|
||||
return removeFromTextBunLock(textLockPath, packageName)
|
||||
}
|
||||
|
||||
// Binary lockfiles cannot be parsed; deletion forces bun to re-resolve
|
||||
if (fs.existsSync(binaryLockPath)) {
|
||||
return deleteBinaryBunLock(binaryLockPath)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function invalidatePackage(packageName: string = PACKAGE_NAME): boolean {
|
||||
try {
|
||||
const pkgDir = path.join(CACHE_DIR, "node_modules", packageName)
|
||||
const pkgJsonPath = path.join(CACHE_DIR, "package.json")
|
||||
const pkgDirs = [
|
||||
path.join(USER_CONFIG_DIR, "node_modules", packageName),
|
||||
path.join(CACHE_DIR, "node_modules", packageName),
|
||||
]
|
||||
|
||||
let packageRemoved = false
|
||||
let dependencyRemoved = false
|
||||
let lockRemoved = false
|
||||
|
||||
if (fs.existsSync(pkgDir)) {
|
||||
fs.rmSync(pkgDir, { recursive: true, force: true })
|
||||
log(`[auto-update-checker] Package removed: ${pkgDir}`)
|
||||
packageRemoved = true
|
||||
}
|
||||
|
||||
if (fs.existsSync(pkgJsonPath)) {
|
||||
const content = fs.readFileSync(pkgJsonPath, "utf-8")
|
||||
const pkgJson = JSON.parse(content)
|
||||
if (pkgJson.dependencies?.[packageName]) {
|
||||
delete pkgJson.dependencies[packageName]
|
||||
fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2))
|
||||
log(`[auto-update-checker] Dependency removed from package.json: ${packageName}`)
|
||||
dependencyRemoved = true
|
||||
for (const pkgDir of pkgDirs) {
|
||||
if (fs.existsSync(pkgDir)) {
|
||||
fs.rmSync(pkgDir, { recursive: true, force: true })
|
||||
log(`[auto-update-checker] Package removed: ${pkgDir}`)
|
||||
packageRemoved = true
|
||||
}
|
||||
}
|
||||
|
||||
lockRemoved = removeFromBunLock(packageName)
|
||||
|
||||
if (!packageRemoved && !dependencyRemoved && !lockRemoved) {
|
||||
if (!packageRemoved && !lockRemoved) {
|
||||
log(`[auto-update-checker] Package not found, nothing to invalidate: ${packageName}`)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,284 +1,10 @@
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import type { NpmDistTags, OpencodeConfig, PackageJson, UpdateCheckResult } from "./types"
|
||||
import {
|
||||
PACKAGE_NAME,
|
||||
NPM_REGISTRY_URL,
|
||||
NPM_FETCH_TIMEOUT,
|
||||
INSTALLED_PACKAGE_JSON,
|
||||
USER_OPENCODE_CONFIG,
|
||||
USER_OPENCODE_CONFIG_JSONC,
|
||||
USER_CONFIG_DIR,
|
||||
getWindowsAppdataDir,
|
||||
} from "./constants"
|
||||
import * as os from "node:os"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
export function isLocalDevMode(directory: string): boolean {
|
||||
return getLocalDevPath(directory) !== null
|
||||
}
|
||||
|
||||
function stripJsonComments(json: string): string {
|
||||
return json
|
||||
.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, g) => (g ? "" : m))
|
||||
.replace(/,(\s*[}\]])/g, "$1")
|
||||
}
|
||||
|
||||
function getConfigPaths(directory: string): string[] {
|
||||
const paths = [
|
||||
path.join(directory, ".opencode", "opencode.json"),
|
||||
path.join(directory, ".opencode", "opencode.jsonc"),
|
||||
USER_OPENCODE_CONFIG,
|
||||
USER_OPENCODE_CONFIG_JSONC,
|
||||
]
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const crossPlatformDir = path.join(os.homedir(), ".config")
|
||||
const appdataDir = getWindowsAppdataDir()
|
||||
|
||||
if (appdataDir) {
|
||||
const alternateDir = USER_CONFIG_DIR === crossPlatformDir ? appdataDir : crossPlatformDir
|
||||
const alternateConfig = path.join(alternateDir, "opencode", "opencode.json")
|
||||
const alternateConfigJsonc = path.join(alternateDir, "opencode", "opencode.jsonc")
|
||||
|
||||
if (!paths.includes(alternateConfig)) {
|
||||
paths.push(alternateConfig)
|
||||
}
|
||||
if (!paths.includes(alternateConfigJsonc)) {
|
||||
paths.push(alternateConfigJsonc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
export function getLocalDevPath(directory: string): string | null {
|
||||
for (const configPath of getConfigPaths(directory)) {
|
||||
try {
|
||||
if (!fs.existsSync(configPath)) continue
|
||||
const content = fs.readFileSync(configPath, "utf-8")
|
||||
const config = JSON.parse(stripJsonComments(content)) as OpencodeConfig
|
||||
const plugins = config.plugin ?? []
|
||||
|
||||
for (const entry of plugins) {
|
||||
if (entry.startsWith("file://") && entry.includes(PACKAGE_NAME)) {
|
||||
try {
|
||||
return fileURLToPath(entry)
|
||||
} catch {
|
||||
return entry.replace("file://", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function findPackageJsonUp(startPath: string): string | null {
|
||||
try {
|
||||
const stat = fs.statSync(startPath)
|
||||
let dir = stat.isDirectory() ? startPath : path.dirname(startPath)
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const pkgPath = path.join(dir, "package.json")
|
||||
if (fs.existsSync(pkgPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(pkgPath, "utf-8")
|
||||
const pkg = JSON.parse(content) as PackageJson
|
||||
if (pkg.name === PACKAGE_NAME) return pkgPath
|
||||
} catch {}
|
||||
}
|
||||
const parent = path.dirname(dir)
|
||||
if (parent === dir) break
|
||||
dir = parent
|
||||
}
|
||||
} catch {}
|
||||
return null
|
||||
}
|
||||
|
||||
export function getLocalDevVersion(directory: string): string | null {
|
||||
const localPath = getLocalDevPath(directory)
|
||||
if (!localPath) return null
|
||||
|
||||
try {
|
||||
const pkgPath = findPackageJsonUp(localPath)
|
||||
if (!pkgPath) return null
|
||||
const content = fs.readFileSync(pkgPath, "utf-8")
|
||||
const pkg = JSON.parse(content) as PackageJson
|
||||
return pkg.version ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export interface PluginEntryInfo {
|
||||
entry: string
|
||||
isPinned: boolean
|
||||
pinnedVersion: string | null
|
||||
configPath: string
|
||||
}
|
||||
|
||||
export function findPluginEntry(directory: string): PluginEntryInfo | null {
|
||||
for (const configPath of getConfigPaths(directory)) {
|
||||
try {
|
||||
if (!fs.existsSync(configPath)) continue
|
||||
const content = fs.readFileSync(configPath, "utf-8")
|
||||
const config = JSON.parse(stripJsonComments(content)) as OpencodeConfig
|
||||
const plugins = config.plugin ?? []
|
||||
|
||||
for (const entry of plugins) {
|
||||
if (entry === PACKAGE_NAME) {
|
||||
return { entry, isPinned: false, pinnedVersion: null, configPath }
|
||||
}
|
||||
if (entry.startsWith(`${PACKAGE_NAME}@`)) {
|
||||
const pinnedVersion = entry.slice(PACKAGE_NAME.length + 1)
|
||||
const isPinned = pinnedVersion !== "latest"
|
||||
return { entry, isPinned, pinnedVersion: isPinned ? pinnedVersion : null, configPath }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function getCachedVersion(): string | null {
|
||||
try {
|
||||
if (fs.existsSync(INSTALLED_PACKAGE_JSON)) {
|
||||
const content = fs.readFileSync(INSTALLED_PACKAGE_JSON, "utf-8")
|
||||
const pkg = JSON.parse(content) as PackageJson
|
||||
if (pkg.version) return pkg.version
|
||||
}
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pkgPath = findPackageJsonUp(currentDir)
|
||||
if (pkgPath) {
|
||||
const content = fs.readFileSync(pkgPath, "utf-8")
|
||||
const pkg = JSON.parse(content) as PackageJson
|
||||
if (pkg.version) return pkg.version
|
||||
}
|
||||
} catch (err) {
|
||||
log("[auto-update-checker] Failed to resolve version from current directory:", err)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a pinned version entry in the config file.
|
||||
* Only replaces within the "plugin" array to avoid unintended edits.
|
||||
* Preserves JSONC comments and formatting via string replacement.
|
||||
*/
|
||||
export function updatePinnedVersion(configPath: string, oldEntry: string, newVersion: string): boolean {
|
||||
try {
|
||||
const content = fs.readFileSync(configPath, "utf-8")
|
||||
const newEntry = `${PACKAGE_NAME}@${newVersion}`
|
||||
|
||||
// Find the "plugin" array region to scope replacement
|
||||
const pluginMatch = content.match(/"plugin"\s*:\s*\[/)
|
||||
if (!pluginMatch || pluginMatch.index === undefined) {
|
||||
log(`[auto-update-checker] No "plugin" array found in ${configPath}`)
|
||||
return false
|
||||
}
|
||||
|
||||
// Find the closing bracket of the plugin array
|
||||
const startIdx = pluginMatch.index + pluginMatch[0].length
|
||||
let bracketCount = 1
|
||||
let endIdx = startIdx
|
||||
|
||||
for (let i = startIdx; i < content.length && bracketCount > 0; i++) {
|
||||
if (content[i] === "[") bracketCount++
|
||||
else if (content[i] === "]") bracketCount--
|
||||
endIdx = i
|
||||
}
|
||||
|
||||
const before = content.slice(0, startIdx)
|
||||
const pluginArrayContent = content.slice(startIdx, endIdx)
|
||||
const after = content.slice(endIdx)
|
||||
|
||||
// Only replace first occurrence within plugin array
|
||||
const escapedOldEntry = oldEntry.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
const regex = new RegExp(`["']${escapedOldEntry}["']`)
|
||||
|
||||
if (!regex.test(pluginArrayContent)) {
|
||||
log(`[auto-update-checker] Entry "${oldEntry}" not found in plugin array of ${configPath}`)
|
||||
return false
|
||||
}
|
||||
|
||||
const updatedPluginArray = pluginArrayContent.replace(regex, `"${newEntry}"`)
|
||||
const updatedContent = before + updatedPluginArray + after
|
||||
|
||||
if (updatedContent === content) {
|
||||
log(`[auto-update-checker] No changes made to ${configPath}`)
|
||||
return false
|
||||
}
|
||||
|
||||
fs.writeFileSync(configPath, updatedContent, "utf-8")
|
||||
log(`[auto-update-checker] Updated ${configPath}: ${oldEntry} → ${newEntry}`)
|
||||
return true
|
||||
} catch (err) {
|
||||
log(`[auto-update-checker] Failed to update config file ${configPath}:`, err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLatestVersion(channel: string = "latest"): Promise<string | null> {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), NPM_FETCH_TIMEOUT)
|
||||
|
||||
try {
|
||||
const response = await fetch(NPM_REGISTRY_URL, {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: "application/json" },
|
||||
})
|
||||
|
||||
if (!response.ok) return null
|
||||
|
||||
const data = (await response.json()) as NpmDistTags
|
||||
return data[channel] ?? data.latest ?? null
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkForUpdate(directory: string): Promise<UpdateCheckResult> {
|
||||
if (isLocalDevMode(directory)) {
|
||||
log("[auto-update-checker] Local dev mode detected, skipping update check")
|
||||
return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: true, isPinned: false }
|
||||
}
|
||||
|
||||
const pluginInfo = findPluginEntry(directory)
|
||||
if (!pluginInfo) {
|
||||
log("[auto-update-checker] Plugin not found in config")
|
||||
return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: false, isPinned: false }
|
||||
}
|
||||
|
||||
const currentVersion = getCachedVersion() ?? pluginInfo.pinnedVersion
|
||||
if (!currentVersion) {
|
||||
log("[auto-update-checker] No cached version found")
|
||||
return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: false, isPinned: false }
|
||||
}
|
||||
|
||||
const { extractChannel } = await import("./index")
|
||||
const channel = extractChannel(pluginInfo.pinnedVersion ?? currentVersion)
|
||||
const latestVersion = await getLatestVersion(channel)
|
||||
if (!latestVersion) {
|
||||
log("[auto-update-checker] Failed to fetch latest version for channel:", channel)
|
||||
return { needsUpdate: false, currentVersion, latestVersion: null, isLocalDev: false, isPinned: pluginInfo.isPinned }
|
||||
}
|
||||
|
||||
const needsUpdate = currentVersion !== latestVersion
|
||||
log(`[auto-update-checker] Current: ${currentVersion}, Latest (${channel}): ${latestVersion}, NeedsUpdate: ${needsUpdate}`)
|
||||
return { needsUpdate, currentVersion, latestVersion, isLocalDev: false, isPinned: pluginInfo.isPinned }
|
||||
}
|
||||
export { isLocalDevMode, getLocalDevPath } from "./checker/local-dev-path"
|
||||
export { getLocalDevVersion } from "./checker/local-dev-version"
|
||||
export { findPluginEntry } from "./checker/plugin-entry"
|
||||
export type { PluginEntryInfo } from "./checker/plugin-entry"
|
||||
export { getCachedVersion } from "./checker/cached-version"
|
||||
export { updatePinnedVersion } from "./checker/pinned-version-updater"
|
||||
export { getLatestVersion } from "./checker/latest-version"
|
||||
export { checkForUpdate } from "./checker/check-for-update"
|
||||
export { syncCachePackageJsonToIntent } from "./checker/sync-package-json"
|
||||
export type { SyncResult } from "./checker/sync-package-json"
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { log } from "../../../shared/logger"
|
||||
import type { PackageJson } from "../types"
|
||||
import { INSTALLED_PACKAGE_JSON } from "../constants"
|
||||
import { findPackageJsonUp } from "./package-json-locator"
|
||||
|
||||
export function getCachedVersion(): string | null {
|
||||
try {
|
||||
if (fs.existsSync(INSTALLED_PACKAGE_JSON)) {
|
||||
const content = fs.readFileSync(INSTALLED_PACKAGE_JSON, "utf-8")
|
||||
const pkg = JSON.parse(content) as PackageJson
|
||||
if (pkg.version) return pkg.version
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pkgPath = findPackageJsonUp(currentDir)
|
||||
if (pkgPath) {
|
||||
const content = fs.readFileSync(pkgPath, "utf-8")
|
||||
const pkg = JSON.parse(content) as PackageJson
|
||||
if (pkg.version) return pkg.version
|
||||
}
|
||||
} catch (err) {
|
||||
log("[auto-update-checker] Failed to resolve version from current directory:", err)
|
||||
}
|
||||
|
||||
try {
|
||||
const execDir = path.dirname(fs.realpathSync(process.execPath))
|
||||
const pkgPath = findPackageJsonUp(execDir)
|
||||
if (pkgPath) {
|
||||
const content = fs.readFileSync(pkgPath, "utf-8")
|
||||
const pkg = JSON.parse(content) as PackageJson
|
||||
if (pkg.version) return pkg.version
|
||||
}
|
||||
} catch (err) {
|
||||
log("[auto-update-checker] Failed to resolve version from execPath:", err)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { log } from "../../../shared/logger"
|
||||
import type { UpdateCheckResult } from "../types"
|
||||
import { extractChannel } from "../version-channel"
|
||||
import { isLocalDevMode } from "./local-dev-path"
|
||||
import { findPluginEntry } from "./plugin-entry"
|
||||
import { getCachedVersion } from "./cached-version"
|
||||
import { getLatestVersion } from "./latest-version"
|
||||
|
||||
export async function checkForUpdate(directory: string): Promise<UpdateCheckResult> {
|
||||
if (isLocalDevMode(directory)) {
|
||||
log("[auto-update-checker] Local dev mode detected, skipping update check")
|
||||
return {
|
||||
needsUpdate: false,
|
||||
currentVersion: null,
|
||||
latestVersion: null,
|
||||
isLocalDev: true,
|
||||
isPinned: false,
|
||||
}
|
||||
}
|
||||
|
||||
const pluginInfo = findPluginEntry(directory)
|
||||
if (!pluginInfo) {
|
||||
log("[auto-update-checker] Plugin not found in config")
|
||||
return {
|
||||
needsUpdate: false,
|
||||
currentVersion: null,
|
||||
latestVersion: null,
|
||||
isLocalDev: false,
|
||||
isPinned: false,
|
||||
}
|
||||
}
|
||||
|
||||
const currentVersion = getCachedVersion() ?? pluginInfo.pinnedVersion
|
||||
if (!currentVersion) {
|
||||
log("[auto-update-checker] No cached version found")
|
||||
return {
|
||||
needsUpdate: false,
|
||||
currentVersion: null,
|
||||
latestVersion: null,
|
||||
isLocalDev: false,
|
||||
isPinned: false,
|
||||
}
|
||||
}
|
||||
|
||||
const channel = extractChannel(pluginInfo.pinnedVersion ?? currentVersion)
|
||||
const latestVersion = await getLatestVersion(channel)
|
||||
if (!latestVersion) {
|
||||
log("[auto-update-checker] Failed to fetch latest version for channel:", channel)
|
||||
return {
|
||||
needsUpdate: false,
|
||||
currentVersion,
|
||||
latestVersion: null,
|
||||
isLocalDev: false,
|
||||
isPinned: pluginInfo.isPinned,
|
||||
}
|
||||
}
|
||||
|
||||
const needsUpdate = currentVersion !== latestVersion
|
||||
log(
|
||||
`[auto-update-checker] Current: ${currentVersion}, Latest (${channel}): ${latestVersion}, NeedsUpdate: ${needsUpdate}`
|
||||
)
|
||||
return {
|
||||
needsUpdate,
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
isLocalDev: false,
|
||||
isPinned: pluginInfo.isPinned,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as os from "node:os"
|
||||
import * as path from "node:path"
|
||||
import {
|
||||
USER_CONFIG_DIR,
|
||||
USER_OPENCODE_CONFIG,
|
||||
USER_OPENCODE_CONFIG_JSONC,
|
||||
getWindowsAppdataDir,
|
||||
} from "../constants"
|
||||
|
||||
export function getConfigPaths(directory: string): string[] {
|
||||
const paths = [
|
||||
path.join(directory, ".opencode", "opencode.json"),
|
||||
path.join(directory, ".opencode", "opencode.jsonc"),
|
||||
USER_OPENCODE_CONFIG,
|
||||
USER_OPENCODE_CONFIG_JSONC,
|
||||
]
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const crossPlatformDir = path.join(os.homedir(), ".config")
|
||||
const appdataDir = getWindowsAppdataDir()
|
||||
|
||||
if (appdataDir) {
|
||||
const alternateDir = USER_CONFIG_DIR === crossPlatformDir ? appdataDir : crossPlatformDir
|
||||
const alternateConfig = path.join(alternateDir, "opencode", "opencode.json")
|
||||
const alternateConfigJsonc = path.join(alternateDir, "opencode", "opencode.jsonc")
|
||||
|
||||
if (!paths.includes(alternateConfig)) {
|
||||
paths.push(alternateConfig)
|
||||
}
|
||||
if (!paths.includes(alternateConfigJsonc)) {
|
||||
paths.push(alternateConfigJsonc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function stripJsonComments(json: string): string {
|
||||
return json
|
||||
.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (match, group) =>
|
||||
group ? "" : match
|
||||
)
|
||||
.replace(/,(\s*[}\]])/g, "$1")
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NPM_FETCH_TIMEOUT, NPM_REGISTRY_URL } from "../constants"
|
||||
import type { NpmDistTags } from "../types"
|
||||
|
||||
export async function getLatestVersion(channel: string = "latest"): Promise<string | null> {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), NPM_FETCH_TIMEOUT)
|
||||
|
||||
try {
|
||||
const response = await fetch(NPM_REGISTRY_URL, {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: "application/json" },
|
||||
})
|
||||
|
||||
if (!response.ok) return null
|
||||
|
||||
const data = (await response.json()) as NpmDistTags
|
||||
return data[channel] ?? data.latest ?? null
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as fs from "node:fs"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import type { OpencodeConfig } from "../types"
|
||||
import { PACKAGE_NAME } from "../constants"
|
||||
import { getConfigPaths } from "./config-paths"
|
||||
import { stripJsonComments } from "./jsonc-strip"
|
||||
|
||||
export function isLocalDevMode(directory: string): boolean {
|
||||
return getLocalDevPath(directory) !== null
|
||||
}
|
||||
|
||||
export function getLocalDevPath(directory: string): string | null {
|
||||
for (const configPath of getConfigPaths(directory)) {
|
||||
try {
|
||||
if (!fs.existsSync(configPath)) continue
|
||||
const content = fs.readFileSync(configPath, "utf-8")
|
||||
const config = JSON.parse(stripJsonComments(content)) as OpencodeConfig
|
||||
const plugins = config.plugin ?? []
|
||||
|
||||
for (const entry of plugins) {
|
||||
if (entry.startsWith("file://") && entry.includes(PACKAGE_NAME)) {
|
||||
try {
|
||||
return fileURLToPath(entry)
|
||||
} catch {
|
||||
return entry.replace("file://", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as fs from "node:fs"
|
||||
import type { PackageJson } from "../types"
|
||||
import { getLocalDevPath } from "./local-dev-path"
|
||||
import { findPackageJsonUp } from "./package-json-locator"
|
||||
|
||||
export function getLocalDevVersion(directory: string): string | null {
|
||||
const localPath = getLocalDevPath(directory)
|
||||
if (!localPath) return null
|
||||
|
||||
try {
|
||||
const pkgPath = findPackageJsonUp(localPath)
|
||||
if (!pkgPath) return null
|
||||
const content = fs.readFileSync(pkgPath, "utf-8")
|
||||
const pkg = JSON.parse(content) as PackageJson
|
||||
return pkg.version ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import type { PackageJson } from "../types"
|
||||
import { PACKAGE_NAME } from "../constants"
|
||||
|
||||
export function findPackageJsonUp(startPath: string): string | null {
|
||||
try {
|
||||
const stat = fs.statSync(startPath)
|
||||
let dir = stat.isDirectory() ? startPath : path.dirname(startPath)
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const pkgPath = path.join(dir, "package.json")
|
||||
if (fs.existsSync(pkgPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(pkgPath, "utf-8")
|
||||
const pkg = JSON.parse(content) as PackageJson
|
||||
if (pkg.name === PACKAGE_NAME) return pkgPath
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
const parent = path.dirname(dir)
|
||||
if (parent === dir) break
|
||||
dir = parent
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import * as os from "node:os"
|
||||
import { updatePinnedVersion, revertPinnedVersion } from "./pinned-version-updater"
|
||||
|
||||
describe("pinned-version-updater", () => {
|
||||
let tmpDir: string
|
||||
let configPath: string
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omo-updater-test-"))
|
||||
configPath = path.join(tmpDir, "opencode.json")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("updatePinnedVersion", () => {
|
||||
test("updates pinned version in config", () => {
|
||||
//#given
|
||||
const config = JSON.stringify({
|
||||
plugin: ["oh-my-opencode@3.1.8"],
|
||||
})
|
||||
fs.writeFileSync(configPath, config)
|
||||
|
||||
//#when
|
||||
const result = updatePinnedVersion(configPath, "oh-my-opencode@3.1.8", "3.4.0")
|
||||
|
||||
//#then
|
||||
expect(result).toBe(true)
|
||||
const updated = fs.readFileSync(configPath, "utf-8")
|
||||
expect(updated).toContain("oh-my-opencode@3.4.0")
|
||||
expect(updated).not.toContain("oh-my-opencode@3.1.8")
|
||||
})
|
||||
|
||||
test("returns false when entry not found", () => {
|
||||
//#given
|
||||
const config = JSON.stringify({
|
||||
plugin: ["some-other-plugin"],
|
||||
})
|
||||
fs.writeFileSync(configPath, config)
|
||||
|
||||
//#when
|
||||
const result = updatePinnedVersion(configPath, "oh-my-opencode@3.1.8", "3.4.0")
|
||||
|
||||
//#then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false when no plugin array exists", () => {
|
||||
//#given
|
||||
const config = JSON.stringify({ agent: {} })
|
||||
fs.writeFileSync(configPath, config)
|
||||
|
||||
//#when
|
||||
const result = updatePinnedVersion(configPath, "oh-my-opencode@3.1.8", "3.4.0")
|
||||
|
||||
//#then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("revertPinnedVersion", () => {
|
||||
test("reverts from failed version back to original entry", () => {
|
||||
//#given
|
||||
const config = JSON.stringify({
|
||||
plugin: ["oh-my-opencode@3.4.0"],
|
||||
})
|
||||
fs.writeFileSync(configPath, config)
|
||||
|
||||
//#when
|
||||
const result = revertPinnedVersion(configPath, "3.4.0", "oh-my-opencode@3.1.8")
|
||||
|
||||
//#then
|
||||
expect(result).toBe(true)
|
||||
const reverted = fs.readFileSync(configPath, "utf-8")
|
||||
expect(reverted).toContain("oh-my-opencode@3.1.8")
|
||||
expect(reverted).not.toContain("oh-my-opencode@3.4.0")
|
||||
})
|
||||
|
||||
test("reverts to unpinned entry", () => {
|
||||
//#given
|
||||
const config = JSON.stringify({
|
||||
plugin: ["oh-my-opencode@3.4.0"],
|
||||
})
|
||||
fs.writeFileSync(configPath, config)
|
||||
|
||||
//#when
|
||||
const result = revertPinnedVersion(configPath, "3.4.0", "oh-my-opencode")
|
||||
|
||||
//#then
|
||||
expect(result).toBe(true)
|
||||
const reverted = fs.readFileSync(configPath, "utf-8")
|
||||
expect(reverted).toContain('"oh-my-opencode"')
|
||||
expect(reverted).not.toContain("oh-my-opencode@3.4.0")
|
||||
})
|
||||
|
||||
test("returns false when failed version not found", () => {
|
||||
//#given
|
||||
const config = JSON.stringify({
|
||||
plugin: ["oh-my-opencode@3.1.8"],
|
||||
})
|
||||
fs.writeFileSync(configPath, config)
|
||||
|
||||
//#when
|
||||
const result = revertPinnedVersion(configPath, "3.4.0", "oh-my-opencode@3.1.8")
|
||||
|
||||
//#then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("update then revert roundtrip", () => {
|
||||
test("config returns to original state after update + revert", () => {
|
||||
//#given
|
||||
const originalConfig = JSON.stringify({
|
||||
plugin: ["oh-my-opencode@3.1.8"],
|
||||
})
|
||||
fs.writeFileSync(configPath, originalConfig)
|
||||
|
||||
//#when
|
||||
updatePinnedVersion(configPath, "oh-my-opencode@3.1.8", "3.4.0")
|
||||
revertPinnedVersion(configPath, "3.4.0", "oh-my-opencode@3.1.8")
|
||||
|
||||
//#then
|
||||
const finalConfig = fs.readFileSync(configPath, "utf-8")
|
||||
expect(finalConfig).toContain("oh-my-opencode@3.1.8")
|
||||
expect(finalConfig).not.toContain("oh-my-opencode@3.4.0")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import * as fs from "node:fs"
|
||||
import { log } from "../../../shared/logger"
|
||||
import { PACKAGE_NAME } from "../constants"
|
||||
|
||||
function replacePluginEntry(configPath: string, oldEntry: string, newEntry: string): boolean {
|
||||
try {
|
||||
const content = fs.readFileSync(configPath, "utf-8")
|
||||
|
||||
const pluginMatch = content.match(/"plugin"\s*:\s*\[/)
|
||||
if (!pluginMatch || pluginMatch.index === undefined) {
|
||||
log(`[auto-update-checker] No "plugin" array found in ${configPath}`)
|
||||
return false
|
||||
}
|
||||
|
||||
const startIndex = pluginMatch.index + pluginMatch[0].length
|
||||
let bracketCount = 1
|
||||
let endIndex = startIndex
|
||||
|
||||
for (let i = startIndex; i < content.length && bracketCount > 0; i++) {
|
||||
if (content[i] === "[") bracketCount++
|
||||
else if (content[i] === "]") bracketCount--
|
||||
endIndex = i
|
||||
}
|
||||
|
||||
const before = content.slice(0, startIndex)
|
||||
const pluginArrayContent = content.slice(startIndex, endIndex)
|
||||
const after = content.slice(endIndex)
|
||||
|
||||
const escapedOldEntry = oldEntry.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
const regex = new RegExp(`["']${escapedOldEntry}["']`)
|
||||
|
||||
if (!regex.test(pluginArrayContent)) {
|
||||
log(`[auto-update-checker] Entry "${oldEntry}" not found in plugin array of ${configPath}`)
|
||||
return false
|
||||
}
|
||||
|
||||
const updatedPluginArray = pluginArrayContent.replace(regex, `"${newEntry}"`)
|
||||
const updatedContent = before + updatedPluginArray + after
|
||||
|
||||
if (updatedContent === content) {
|
||||
log(`[auto-update-checker] No changes made to ${configPath}`)
|
||||
return false
|
||||
}
|
||||
|
||||
fs.writeFileSync(configPath, updatedContent, "utf-8")
|
||||
log(`[auto-update-checker] Updated ${configPath}: ${oldEntry} → ${newEntry}`)
|
||||
return true
|
||||
} catch (err) {
|
||||
log(`[auto-update-checker] Failed to update config file ${configPath}:`, err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function updatePinnedVersion(configPath: string, oldEntry: string, newVersion: string): boolean {
|
||||
const newEntry = `${PACKAGE_NAME}@${newVersion}`
|
||||
return replacePluginEntry(configPath, oldEntry, newEntry)
|
||||
}
|
||||
|
||||
export function revertPinnedVersion(configPath: string, failedVersion: string, originalEntry: string): boolean {
|
||||
const failedEntry = `${PACKAGE_NAME}@${failedVersion}`
|
||||
return replacePluginEntry(configPath, failedEntry, originalEntry)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import * as fs from "node:fs"
|
||||
import * as os from "node:os"
|
||||
import * as path from "node:path"
|
||||
import { findPluginEntry } from "./plugin-entry"
|
||||
|
||||
describe("findPluginEntry", () => {
|
||||
let temporaryDirectory: string
|
||||
let configPath: string
|
||||
|
||||
beforeEach(() => {
|
||||
temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "omo-plugin-entry-test-"))
|
||||
const opencodeDirectory = path.join(temporaryDirectory, ".opencode")
|
||||
fs.mkdirSync(opencodeDirectory, { recursive: true })
|
||||
configPath = path.join(opencodeDirectory, "opencode.json")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(temporaryDirectory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test("returns unpinned for bare package name", () => {
|
||||
// #given plugin is configured without a tag
|
||||
fs.writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-opencode"] }))
|
||||
|
||||
// #when plugin entry is detected
|
||||
const pluginInfo = findPluginEntry(temporaryDirectory)
|
||||
|
||||
// #then entry is not pinned
|
||||
expect(pluginInfo).not.toBeNull()
|
||||
expect(pluginInfo?.isPinned).toBe(false)
|
||||
expect(pluginInfo?.pinnedVersion).toBeNull()
|
||||
})
|
||||
|
||||
test("returns unpinned for latest dist-tag", () => {
|
||||
// #given plugin is configured with latest dist-tag
|
||||
fs.writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-opencode@latest"] }))
|
||||
|
||||
// #when plugin entry is detected
|
||||
const pluginInfo = findPluginEntry(temporaryDirectory)
|
||||
|
||||
// #then latest is treated as channel, not pin
|
||||
expect(pluginInfo).not.toBeNull()
|
||||
expect(pluginInfo?.isPinned).toBe(false)
|
||||
expect(pluginInfo?.pinnedVersion).toBe("latest")
|
||||
})
|
||||
|
||||
test("returns unpinned for beta dist-tag", () => {
|
||||
// #given plugin is configured with beta dist-tag
|
||||
fs.writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-opencode@beta"] }))
|
||||
|
||||
// #when plugin entry is detected
|
||||
const pluginInfo = findPluginEntry(temporaryDirectory)
|
||||
|
||||
// #then beta is treated as channel, not pin
|
||||
expect(pluginInfo).not.toBeNull()
|
||||
expect(pluginInfo?.isPinned).toBe(false)
|
||||
expect(pluginInfo?.pinnedVersion).toBe("beta")
|
||||
})
|
||||
|
||||
test("returns pinned for explicit semver", () => {
|
||||
// #given plugin is configured with explicit version
|
||||
fs.writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-opencode@3.5.2"] }))
|
||||
|
||||
// #when plugin entry is detected
|
||||
const pluginInfo = findPluginEntry(temporaryDirectory)
|
||||
|
||||
// #then explicit semver is treated as pin
|
||||
expect(pluginInfo).not.toBeNull()
|
||||
expect(pluginInfo?.isPinned).toBe(true)
|
||||
expect(pluginInfo?.pinnedVersion).toBe("3.5.2")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as fs from "node:fs"
|
||||
import type { OpencodeConfig } from "../types"
|
||||
import { PACKAGE_NAME } from "../constants"
|
||||
import { getConfigPaths } from "./config-paths"
|
||||
import { stripJsonComments } from "./jsonc-strip"
|
||||
|
||||
export interface PluginEntryInfo {
|
||||
entry: string
|
||||
isPinned: boolean
|
||||
pinnedVersion: string | null
|
||||
configPath: string
|
||||
}
|
||||
|
||||
const EXACT_SEMVER_REGEX = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/
|
||||
|
||||
export function findPluginEntry(directory: string): PluginEntryInfo | null {
|
||||
for (const configPath of getConfigPaths(directory)) {
|
||||
try {
|
||||
if (!fs.existsSync(configPath)) continue
|
||||
const content = fs.readFileSync(configPath, "utf-8")
|
||||
const config = JSON.parse(stripJsonComments(content)) as OpencodeConfig
|
||||
const plugins = config.plugin ?? []
|
||||
|
||||
for (const entry of plugins) {
|
||||
if (entry === PACKAGE_NAME) {
|
||||
return { entry, isPinned: false, pinnedVersion: null, configPath }
|
||||
}
|
||||
if (entry.startsWith(`${PACKAGE_NAME}@`)) {
|
||||
const pinnedVersion = entry.slice(PACKAGE_NAME.length + 1)
|
||||
const isPinned = EXACT_SEMVER_REGEX.test(pinnedVersion.trim())
|
||||
return { entry, isPinned, pinnedVersion, configPath }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import type { PluginEntryInfo } from "./plugin-entry"
|
||||
|
||||
const TEST_CACHE_DIR = join(import.meta.dir, "__test-sync-cache__")
|
||||
|
||||
mock.module("../constants", () => ({
|
||||
CACHE_DIR: TEST_CACHE_DIR,
|
||||
PACKAGE_NAME: "oh-my-opencode",
|
||||
NPM_REGISTRY_URL: "https://registry.npmjs.org/-/package/oh-my-opencode/dist-tags",
|
||||
NPM_FETCH_TIMEOUT: 5000,
|
||||
VERSION_FILE: join(TEST_CACHE_DIR, "version"),
|
||||
USER_CONFIG_DIR: "/tmp/opencode-config",
|
||||
USER_OPENCODE_CONFIG: "/tmp/opencode-config/opencode.json",
|
||||
USER_OPENCODE_CONFIG_JSONC: "/tmp/opencode-config/opencode.jsonc",
|
||||
INSTALLED_PACKAGE_JSON: join(TEST_CACHE_DIR, "node_modules", "oh-my-opencode", "package.json"),
|
||||
getWindowsAppdataDir: () => null,
|
||||
}))
|
||||
|
||||
mock.module("../../../shared/logger", () => ({
|
||||
log: () => {},
|
||||
}))
|
||||
|
||||
function resetTestCache(currentVersion = "3.10.0"): void {
|
||||
if (existsSync(TEST_CACHE_DIR)) {
|
||||
rmSync(TEST_CACHE_DIR, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
mkdirSync(TEST_CACHE_DIR, { recursive: true })
|
||||
writeFileSync(
|
||||
join(TEST_CACHE_DIR, "package.json"),
|
||||
JSON.stringify({ dependencies: { "oh-my-opencode": currentVersion, other: "1.0.0" } }, null, 2)
|
||||
)
|
||||
}
|
||||
|
||||
function cleanupTestCache(): void {
|
||||
if (existsSync(TEST_CACHE_DIR)) {
|
||||
rmSync(TEST_CACHE_DIR, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function readCachePackageJsonVersion(): string | undefined {
|
||||
const content = readFileSync(join(TEST_CACHE_DIR, "package.json"), "utf-8")
|
||||
const pkg = JSON.parse(content) as { dependencies?: Record<string, string> }
|
||||
return pkg.dependencies?.["oh-my-opencode"]
|
||||
}
|
||||
|
||||
describe("syncCachePackageJsonToIntent", () => {
|
||||
beforeEach(() => {
|
||||
resetTestCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanupTestCache()
|
||||
})
|
||||
|
||||
describe("#given cache package.json with pinned semver version", () => {
|
||||
describe("#when opencode.json intent is latest tag", () => {
|
||||
it("#then updates package.json to use latest", async () => {
|
||||
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
|
||||
|
||||
const pluginInfo: PluginEntryInfo = {
|
||||
entry: "oh-my-opencode@latest",
|
||||
isPinned: false,
|
||||
pinnedVersion: "latest",
|
||||
configPath: "/tmp/opencode.json",
|
||||
}
|
||||
|
||||
const result = syncCachePackageJsonToIntent(pluginInfo)
|
||||
|
||||
expect(result.synced).toBe(true)
|
||||
expect(result.error).toBeNull()
|
||||
expect(readCachePackageJsonVersion()).toBe("latest")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when opencode.json intent is next tag", () => {
|
||||
it("#then updates package.json to use next", async () => {
|
||||
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
|
||||
|
||||
const pluginInfo: PluginEntryInfo = {
|
||||
entry: "oh-my-opencode@next",
|
||||
isPinned: false,
|
||||
pinnedVersion: "next",
|
||||
configPath: "/tmp/opencode.json",
|
||||
}
|
||||
|
||||
const result = syncCachePackageJsonToIntent(pluginInfo)
|
||||
|
||||
expect(result.synced).toBe(true)
|
||||
expect(result.error).toBeNull()
|
||||
expect(readCachePackageJsonVersion()).toBe("next")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when opencode.json has no version (implies latest)", () => {
|
||||
it("#then updates package.json to use latest", async () => {
|
||||
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
|
||||
|
||||
const pluginInfo: PluginEntryInfo = {
|
||||
entry: "oh-my-opencode",
|
||||
isPinned: false,
|
||||
pinnedVersion: null,
|
||||
configPath: "/tmp/opencode.json",
|
||||
}
|
||||
|
||||
const result = syncCachePackageJsonToIntent(pluginInfo)
|
||||
|
||||
expect(result.synced).toBe(true)
|
||||
expect(result.error).toBeNull()
|
||||
expect(readCachePackageJsonVersion()).toBe("latest")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given cache package.json already matches intent", () => {
|
||||
it("#then returns synced false with no error", async () => {
|
||||
resetTestCache("latest")
|
||||
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
|
||||
|
||||
const pluginInfo: PluginEntryInfo = {
|
||||
entry: "oh-my-opencode@latest",
|
||||
isPinned: false,
|
||||
pinnedVersion: "latest",
|
||||
configPath: "/tmp/opencode.json",
|
||||
}
|
||||
|
||||
const result = syncCachePackageJsonToIntent(pluginInfo)
|
||||
|
||||
expect(result.synced).toBe(false)
|
||||
expect(result.error).toBeNull()
|
||||
expect(readCachePackageJsonVersion()).toBe("latest")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given cache package.json does not exist", () => {
|
||||
it("#then returns file_not_found error", async () => {
|
||||
cleanupTestCache()
|
||||
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
|
||||
|
||||
const pluginInfo: PluginEntryInfo = {
|
||||
entry: "oh-my-opencode@latest",
|
||||
isPinned: false,
|
||||
pinnedVersion: "latest",
|
||||
configPath: "/tmp/opencode.json",
|
||||
}
|
||||
|
||||
const result = syncCachePackageJsonToIntent(pluginInfo)
|
||||
|
||||
expect(result.synced).toBe(false)
|
||||
expect(result.error).toBe("file_not_found")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given plugin not in cache package.json dependencies", () => {
|
||||
it("#then returns plugin_not_in_deps error", async () => {
|
||||
cleanupTestCache()
|
||||
mkdirSync(TEST_CACHE_DIR, { recursive: true })
|
||||
writeFileSync(
|
||||
join(TEST_CACHE_DIR, "package.json"),
|
||||
JSON.stringify({ dependencies: { other: "1.0.0" } }, null, 2)
|
||||
)
|
||||
|
||||
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
|
||||
|
||||
const pluginInfo: PluginEntryInfo = {
|
||||
entry: "oh-my-opencode@latest",
|
||||
isPinned: false,
|
||||
pinnedVersion: "latest",
|
||||
configPath: "/tmp/opencode.json",
|
||||
}
|
||||
|
||||
const result = syncCachePackageJsonToIntent(pluginInfo)
|
||||
|
||||
expect(result.synced).toBe(false)
|
||||
expect(result.error).toBe("plugin_not_in_deps")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given user explicitly changed from one semver to another", () => {
|
||||
it("#then updates package.json to new version", async () => {
|
||||
resetTestCache("3.9.0")
|
||||
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
|
||||
|
||||
const pluginInfo: PluginEntryInfo = {
|
||||
entry: "oh-my-opencode@3.10.0",
|
||||
isPinned: true,
|
||||
pinnedVersion: "3.10.0",
|
||||
configPath: "/tmp/opencode.json",
|
||||
}
|
||||
|
||||
const result = syncCachePackageJsonToIntent(pluginInfo)
|
||||
|
||||
expect(result.synced).toBe(true)
|
||||
expect(result.error).toBeNull()
|
||||
expect(readCachePackageJsonVersion()).toBe("3.10.0")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given cache package.json with other dependencies", () => {
|
||||
it("#then other dependencies are preserved when updating plugin version", async () => {
|
||||
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
|
||||
|
||||
const pluginInfo: PluginEntryInfo = {
|
||||
entry: "oh-my-opencode@latest",
|
||||
isPinned: false,
|
||||
pinnedVersion: "latest",
|
||||
configPath: "/tmp/opencode.json",
|
||||
}
|
||||
|
||||
const result = syncCachePackageJsonToIntent(pluginInfo)
|
||||
|
||||
expect(result.synced).toBe(true)
|
||||
expect(result.error).toBeNull()
|
||||
|
||||
const content = readFileSync(join(TEST_CACHE_DIR, "package.json"), "utf-8")
|
||||
const pkg = JSON.parse(content) as { dependencies?: Record<string, string> }
|
||||
expect(pkg.dependencies?.["other"]).toBe("1.0.0")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given malformed JSON in cache package.json", () => {
|
||||
it("#then returns parse_error", async () => {
|
||||
cleanupTestCache()
|
||||
mkdirSync(TEST_CACHE_DIR, { recursive: true })
|
||||
writeFileSync(join(TEST_CACHE_DIR, "package.json"), "{ invalid json }")
|
||||
|
||||
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
|
||||
|
||||
const pluginInfo: PluginEntryInfo = {
|
||||
entry: "oh-my-opencode@latest",
|
||||
isPinned: false,
|
||||
pinnedVersion: "latest",
|
||||
configPath: "/tmp/opencode.json",
|
||||
}
|
||||
|
||||
const result = syncCachePackageJsonToIntent(pluginInfo)
|
||||
|
||||
expect(result.synced).toBe(false)
|
||||
expect(result.error).toBe("parse_error")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given write permission denied", () => {
|
||||
it("#then returns write_error", async () => {
|
||||
cleanupTestCache()
|
||||
mkdirSync(TEST_CACHE_DIR, { recursive: true })
|
||||
writeFileSync(
|
||||
join(TEST_CACHE_DIR, "package.json"),
|
||||
JSON.stringify({ dependencies: { "oh-my-opencode": "3.10.0" } }, null, 2)
|
||||
)
|
||||
|
||||
const fs = await import("node:fs")
|
||||
const originalWriteFileSync = fs.writeFileSync
|
||||
const originalRenameSync = fs.renameSync
|
||||
|
||||
mock.module("node:fs", () => ({
|
||||
...fs,
|
||||
writeFileSync: mock(() => {
|
||||
throw new Error("EACCES: permission denied")
|
||||
}),
|
||||
renameSync: fs.renameSync,
|
||||
}))
|
||||
|
||||
try {
|
||||
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
|
||||
|
||||
const pluginInfo: PluginEntryInfo = {
|
||||
entry: "oh-my-opencode@latest",
|
||||
isPinned: false,
|
||||
pinnedVersion: "latest",
|
||||
configPath: "/tmp/opencode.json",
|
||||
}
|
||||
|
||||
const result = syncCachePackageJsonToIntent(pluginInfo)
|
||||
|
||||
expect(result.synced).toBe(false)
|
||||
expect(result.error).toBe("write_error")
|
||||
} finally {
|
||||
mock.module("node:fs", () => ({
|
||||
...fs,
|
||||
writeFileSync: originalWriteFileSync,
|
||||
renameSync: originalRenameSync,
|
||||
}))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given rename fails after successful write", () => {
|
||||
it("#then returns write_error and cleans up temp file", async () => {
|
||||
cleanupTestCache()
|
||||
mkdirSync(TEST_CACHE_DIR, { recursive: true })
|
||||
writeFileSync(
|
||||
join(TEST_CACHE_DIR, "package.json"),
|
||||
JSON.stringify({ dependencies: { "oh-my-opencode": "3.10.0" } }, null, 2)
|
||||
)
|
||||
|
||||
const fs = await import("node:fs")
|
||||
const originalWriteFileSync = fs.writeFileSync
|
||||
const originalRenameSync = fs.renameSync
|
||||
|
||||
let tempFilePath: string | null = null
|
||||
|
||||
mock.module("node:fs", () => ({
|
||||
...fs,
|
||||
writeFileSync: mock((path: string, data: string) => {
|
||||
tempFilePath = path
|
||||
return originalWriteFileSync(path, data)
|
||||
}),
|
||||
renameSync: mock(() => {
|
||||
throw new Error("EXDEV: cross-device link not permitted")
|
||||
}),
|
||||
}))
|
||||
|
||||
try {
|
||||
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
|
||||
|
||||
const pluginInfo: PluginEntryInfo = {
|
||||
entry: "oh-my-opencode@latest",
|
||||
isPinned: false,
|
||||
pinnedVersion: "latest",
|
||||
configPath: "/tmp/opencode.json",
|
||||
}
|
||||
|
||||
const result = syncCachePackageJsonToIntent(pluginInfo)
|
||||
|
||||
expect(result.synced).toBe(false)
|
||||
expect(result.error).toBe("write_error")
|
||||
expect(tempFilePath).not.toBeNull()
|
||||
expect(existsSync(tempFilePath!)).toBe(false)
|
||||
} finally {
|
||||
mock.module("node:fs", () => ({
|
||||
...fs,
|
||||
writeFileSync: originalWriteFileSync,
|
||||
renameSync: originalRenameSync,
|
||||
}))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
import * as crypto from "node:crypto"
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import { CACHE_DIR, PACKAGE_NAME } from "../constants"
|
||||
import { log } from "../../../shared/logger"
|
||||
import type { PluginEntryInfo } from "./plugin-entry"
|
||||
|
||||
interface CachePackageJson {
|
||||
dependencies?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
synced: boolean
|
||||
error: "file_not_found" | "plugin_not_in_deps" | "parse_error" | "write_error" | null
|
||||
message?: string
|
||||
}
|
||||
|
||||
const EXACT_SEMVER_REGEX = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/
|
||||
|
||||
function safeUnlink(filePath: string): void {
|
||||
try {
|
||||
fs.unlinkSync(filePath)
|
||||
} catch (err) {
|
||||
log(`[auto-update-checker] Failed to cleanup temp file: ${filePath}`, err)
|
||||
}
|
||||
}
|
||||
|
||||
function getIntentVersion(pluginInfo: PluginEntryInfo): string {
|
||||
if (!pluginInfo.pinnedVersion) {
|
||||
return "latest"
|
||||
}
|
||||
return pluginInfo.pinnedVersion
|
||||
}
|
||||
|
||||
export function syncCachePackageJsonToIntent(pluginInfo: PluginEntryInfo): SyncResult {
|
||||
const cachePackageJsonPath = path.join(CACHE_DIR, "package.json")
|
||||
|
||||
if (!fs.existsSync(cachePackageJsonPath)) {
|
||||
log("[auto-update-checker] Cache package.json not found, nothing to sync")
|
||||
return { synced: false, error: "file_not_found", message: "Cache package.json not found" }
|
||||
}
|
||||
|
||||
let content: string
|
||||
let pkgJson: CachePackageJson
|
||||
|
||||
try {
|
||||
content = fs.readFileSync(cachePackageJsonPath, "utf-8")
|
||||
} catch (err) {
|
||||
log("[auto-update-checker] Failed to read cache package.json:", err)
|
||||
return { synced: false, error: "parse_error", message: "Failed to read cache package.json" }
|
||||
}
|
||||
|
||||
try {
|
||||
pkgJson = JSON.parse(content) as CachePackageJson
|
||||
} catch (err) {
|
||||
log("[auto-update-checker] Failed to parse cache package.json:", err)
|
||||
return { synced: false, error: "parse_error", message: "Failed to parse cache package.json (malformed JSON)" }
|
||||
}
|
||||
|
||||
if (!pkgJson || !pkgJson.dependencies?.[PACKAGE_NAME]) {
|
||||
log("[auto-update-checker] Plugin not in cache package.json dependencies, nothing to sync")
|
||||
return { synced: false, error: "plugin_not_in_deps", message: "Plugin not in cache package.json dependencies" }
|
||||
}
|
||||
|
||||
const currentVersion = pkgJson.dependencies[PACKAGE_NAME]
|
||||
const intentVersion = getIntentVersion(pluginInfo)
|
||||
|
||||
if (currentVersion === intentVersion) {
|
||||
log("[auto-update-checker] Cache package.json already matches intent:", intentVersion)
|
||||
return { synced: false, error: null, message: `Already matches intent: ${intentVersion}` }
|
||||
}
|
||||
|
||||
const intentIsTag = !EXACT_SEMVER_REGEX.test(intentVersion.trim())
|
||||
const currentIsSemver = EXACT_SEMVER_REGEX.test(String(currentVersion).trim())
|
||||
|
||||
if (intentIsTag && currentIsSemver) {
|
||||
log(
|
||||
`[auto-update-checker] Syncing cache package.json: "${currentVersion}" → "${intentVersion}" (opencode.json intent)`
|
||||
)
|
||||
} else {
|
||||
log(
|
||||
`[auto-update-checker] Updating cache package.json: "${currentVersion}" → "${intentVersion}"`
|
||||
)
|
||||
}
|
||||
|
||||
pkgJson.dependencies[PACKAGE_NAME] = intentVersion
|
||||
|
||||
const tmpPath = `${cachePackageJsonPath}.${crypto.randomUUID()}`
|
||||
try {
|
||||
fs.writeFileSync(tmpPath, JSON.stringify(pkgJson, null, 2))
|
||||
fs.renameSync(tmpPath, cachePackageJsonPath)
|
||||
return { synced: true, error: null, message: `Updated: "${currentVersion}" → "${intentVersion}"` }
|
||||
} catch (err) {
|
||||
log("[auto-update-checker] Failed to write cache package.json:", err)
|
||||
safeUnlink(tmpPath)
|
||||
return { synced: false, error: "write_error", message: "Failed to write cache package.json" }
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user