Compare commits
40 Commits
fix/docs-o
...
v3.12.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d80833896c | ||
|
|
d50c38f037 | ||
|
|
f2d5f4ca92 | ||
|
|
b788586caf | ||
|
|
90351e442e | ||
|
|
4ad88b2576 | ||
|
|
2ce69710e3 | ||
|
|
0b4d092cf6 | ||
|
|
53285617d3 | ||
|
|
ae3befbfbe | ||
|
|
dc1a05ac3e | ||
|
|
e271b4a1b0 | ||
|
|
fee938d63a | ||
|
|
4d74d888e4 | ||
|
|
4bc7b1d27c | ||
|
|
78dac0642e | ||
|
|
92bc72a90b | ||
|
|
a7301ba8a9 | ||
|
|
e9887dd82f | ||
|
|
c0082d8a09 | ||
|
|
fbc3b4e230 | ||
|
|
1f7fdb43ba | ||
|
|
566031f4fa | ||
|
|
0cf386ec52 | ||
|
|
d493f9ec3a | ||
|
|
2c7ded2433 | ||
|
|
82c7807a4f | ||
|
|
df7e1ae16d | ||
|
|
0471078006 | ||
|
|
1070b9170f | ||
|
|
bb312711cf | ||
|
|
c31facf41e | ||
|
|
de66f1f397 | ||
|
|
427fa6d7a2 | ||
|
|
239da8b02a | ||
|
|
17244e2c84 | ||
|
|
24a0f7b032 | ||
|
|
fc48df1d53 | ||
|
|
3055454ecc | ||
|
|
11e9276498 |
@@ -1,9 +1,3 @@
|
||||
> [!WARNING]
|
||||
> **TEMP NOTICE (This Week): Reduced Maintainer Availability**
|
||||
>
|
||||
> Core maintainer Q got injured, so issue/PR responses and releases may be delayed this week.
|
||||
> Thank you for your patience and support.
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> [](https://sisyphuslabs.ai)
|
||||
|
||||
@@ -3699,6 +3699,32 @@
|
||||
"syncPollTimeoutMs": {
|
||||
"type": "number",
|
||||
"minimum": 60000
|
||||
},
|
||||
"maxToolCalls": {
|
||||
"type": "integer",
|
||||
"minimum": 10,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"circuitBreaker": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"maxToolCalls": {
|
||||
"type": "integer",
|
||||
"minimum": 10,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"windowSize": {
|
||||
"type": "integer",
|
||||
"minimum": 5,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"repetitionThresholdPercent": {
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0,
|
||||
"maximum": 100
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"name": "hashline-edit-benchmark",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Hashline edit tool benchmark using Vercel AI SDK with FriendliAI provider",
|
||||
"scripts": {
|
||||
"bench:basic": "bun run test-edit-ops.ts",
|
||||
"bench:edge": "bun run test-edge-cases.ts",
|
||||
"bench:multi": "bun run test-multi-model.ts",
|
||||
"bench:all": "bun run bench:basic && bun run bench:edge"
|
||||
},
|
||||
"dependencies": {
|
||||
"@friendliai/ai-provider": "^1.0.9",
|
||||
"ai": "^6.0.94",
|
||||
"zod": "^4.1.0"
|
||||
}
|
||||
}
|
||||
@@ -64,8 +64,8 @@ These agents have Claude-optimized prompts — long, detailed, mechanics-driven.
|
||||
|
||||
| Agent | Role | Fallback Chain | Notes |
|
||||
| ------------ | ----------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| **Sisyphus** | Main orchestrator | Claude Opus → opencode-go/kimi-k2.5 → K2P5 → GPT-5.4 → GLM-5 → Big Pickle | Claude-family first. GPT-5.4 has dedicated prompt support. Kimi/GLM as intermediate fallbacks. |
|
||||
| **Metis** | Plan gap analyzer | Claude Opus → opencode-go/glm-5 → K2P5 | Claude preferred. Uses opencode-go for reliable GLM-5 access. |
|
||||
| **Sisyphus** | Main orchestrator | Claude Opus → opencode-go/kimi-k2.5 → K2P5 → Kimi K2.5 → GPT-5.4 → GLM-5 → Big Pickle | Claude-family first. GPT-5.4 has dedicated prompt support. Kimi available through multiple providers. |
|
||||
| **Metis** | Plan gap analyzer | Claude Opus → GPT-5.4 → opencode-go/glm-5 → K2P5 | Claude preferred. GPT-5.4 as secondary before GLM-5 fallback. |
|
||||
|
||||
### Dual-Prompt Agents → Claude preferred, GPT supported
|
||||
|
||||
@@ -74,7 +74,7 @@ These agents ship separate prompts for Claude and GPT families. They auto-detect
|
||||
| Agent | Role | Fallback Chain | Notes |
|
||||
| -------------- | ----------------- | -------------------------------------- | -------------------------------------------------------------------- |
|
||||
| **Prometheus** | Strategic planner | Claude Opus → GPT-5.4 → opencode-go/glm-5 → Gemini 3.1 Pro | Interview-mode planning. GPT prompt is compact and principle-driven. |
|
||||
| **Atlas** | Todo orchestrator | Claude Sonnet → opencode-go/kimi-k2.5 | Claude first, opencode-go as the current fallback path. |
|
||||
| **Atlas** | Todo orchestrator | Claude Sonnet → opencode-go/kimi-k2.5 → GPT-5.4 | Claude first, opencode-go as intermediate, GPT-5.4 as last resort. |
|
||||
|
||||
### Deep Specialists → GPT
|
||||
|
||||
@@ -82,9 +82,9 @@ These agents are built for GPT's principle-driven style. Their prompts assume au
|
||||
|
||||
| Agent | Role | Fallback Chain | Notes |
|
||||
| -------------- | ----------------------- | -------------------------------------- | ------------------------------------------------ |
|
||||
| **Hephaestus** | Autonomous deep worker | GPT-5.3 Codex only | No fallback. Requires GPT access. The craftsman. |
|
||||
| **Oracle** | Architecture consultant | GPT-5.4 → Gemini 3.1 Pro → Claude Opus | Read-only high-IQ consultation. |
|
||||
| **Momus** | Ruthless reviewer | GPT-5.4 → Claude Opus → Gemini 3.1 Pro | Verification and plan review. |
|
||||
| **Hephaestus** | Autonomous deep worker | GPT-5.3 Codex → GPT-5.4 (Copilot) | Requires GPT access. GPT-5.4 via Copilot as fallback. The craftsman. |
|
||||
| **Oracle** | Architecture consultant | GPT-5.4 → Gemini 3.1 Pro → Claude Opus → opencode-go/glm-5 | Read-only high-IQ consultation. |
|
||||
| **Momus** | Ruthless reviewer | GPT-5.4 → Claude Opus → Gemini 3.1 Pro → opencode-go/glm-5 | Verification and plan review. GPT-5.4 uses xhigh variant. |
|
||||
|
||||
### Utility Runners → Speed over Intelligence
|
||||
|
||||
@@ -95,6 +95,7 @@ These agents do grep, search, and retrieval. They intentionally use the fastest,
|
||||
| **Explore** | Fast codebase grep | Grok Code Fast → opencode-go/minimax-m2.5 → MiniMax Free → Haiku → GPT-5-Nano | Speed is everything. Fire 10 in parallel. |
|
||||
| **Librarian** | Docs/code search | opencode-go/minimax-m2.5 → MiniMax Free → Haiku → GPT-5-Nano | Doc retrieval doesn't need deep reasoning. |
|
||||
| **Multimodal Looker** | Vision/screenshots | GPT-5.4 → opencode-go/kimi-k2.5 → GLM-4.6v → GPT-5-Nano | Uses the first available multimodal-capable fallback. |
|
||||
| **Sisyphus-Junior** | Category executor | Claude Sonnet → opencode-go/kimi-k2.5 → GPT-5.4 → Big Pickle | Handles delegated category tasks. Sonnet-tier default. |
|
||||
|
||||
---
|
||||
|
||||
@@ -119,8 +120,7 @@ Principle-driven, explicit reasoning, deep technical capability. Best for agents
|
||||
| Model | Strengths |
|
||||
| ----------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| **GPT-5.3 Codex** | Deep coding powerhouse. Autonomous exploration. Required for Hephaestus. |
|
||||
| **GPT-5.4** | High intelligence, strategic reasoning. Default for Oracle. |
|
||||
| **GPT-5.4** | Strong principle-driven reasoning. Default for Momus and a key fallback for Prometheus / Atlas. |
|
||||
| **GPT-5.4** | High intelligence, strategic reasoning. Default for Oracle, Momus, and a key fallback for Prometheus / Atlas. Uses xhigh variant for Momus. |
|
||||
| **GPT-5-Nano** | Ultra-cheap, fast. Good for simple utility tasks. |
|
||||
|
||||
### Other Models
|
||||
@@ -166,14 +166,14 @@ When agents delegate work, they don't pick a model name — they pick a **catego
|
||||
|
||||
| Category | When Used | Fallback Chain |
|
||||
| -------------------- | -------------------------- | -------------------------------------------- |
|
||||
| `visual-engineering` | Frontend, UI, CSS, design | Gemini 3.1 Pro → GLM 5 → Claude Opus |
|
||||
| `ultrabrain` | Maximum reasoning needed | GPT-5.4 → Gemini 3.1 Pro → Claude Opus |
|
||||
| `visual-engineering` | Frontend, UI, CSS, design | Gemini 3.1 Pro → GLM 5 → Claude Opus → opencode-go/glm-5 → K2P5 |
|
||||
| `ultrabrain` | Maximum reasoning needed | GPT-5.4 → Gemini 3.1 Pro → Claude Opus → opencode-go/glm-5 |
|
||||
| `deep` | Deep coding, complex logic | GPT-5.3 Codex → Claude Opus → Gemini 3.1 Pro |
|
||||
| `artistry` | Creative, novel approaches | Gemini 3.1 Pro → Claude Opus → GPT-5.4 |
|
||||
| `quick` | Simple, fast tasks | Claude Haiku → Gemini Flash → GPT-5-Nano |
|
||||
| `unspecified-high` | General complex work | Claude Opus → GPT-5.4 (high) → GLM 5 → K2P5 |
|
||||
| `unspecified-low` | General standard work | Claude Sonnet → GPT-5.3 Codex → Gemini Flash |
|
||||
| `writing` | Text, docs, prose | Gemini Flash → Claude Sonnet |
|
||||
| `quick` | Simple, fast tasks | Claude Haiku → Gemini Flash → opencode-go/minimax-m2.5 → GPT-5-Nano |
|
||||
| `unspecified-high` | General complex work | Claude Opus → GPT-5.4 → GLM 5 → K2P5 → opencode-go/glm-5 → Kimi K2.5 |
|
||||
| `unspecified-low` | General standard work | Claude Sonnet → GPT-5.3 Codex → opencode-go/kimi-k2.5 → Gemini Flash |
|
||||
| `writing` | Text, docs, prose | Gemini Flash → opencode-go/kimi-k2.5 → Claude Sonnet |
|
||||
|
||||
See the [Orchestration System Guide](./orchestration.md) for how agents dispatch tasks to categories.
|
||||
|
||||
|
||||
@@ -2207,6 +2207,22 @@
|
||||
"created_at": "2026-03-16T04:55:10Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 2604
|
||||
},
|
||||
{
|
||||
"name": "gxlife",
|
||||
"id": 110413359,
|
||||
"comment_id": 4068427047,
|
||||
"created_at": "2026-03-16T15:17:01Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 2625
|
||||
},
|
||||
{
|
||||
"name": "HaD0Yun",
|
||||
"id": 102889891,
|
||||
"comment_id": 4073195308,
|
||||
"created_at": "2026-03-17T08:27:45Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 2640
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,60 +5,60 @@ exports[`generateModelConfig no providers available returns ULTIMATE_FALLBACK fo
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"atlas": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"hephaestus": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"librarian": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"metis": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"momus": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"multimodal-looker": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"oracle": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"prometheus": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"sisyphus-junior": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
},
|
||||
"categories": {
|
||||
"artistry": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"deep": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"quick": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"unspecified-high": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"unspecified-low": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"writing": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -83,7 +83,7 @@ exports[`generateModelConfig single native provider uses Claude models when only
|
||||
"variant": "max",
|
||||
},
|
||||
"multimodal-looker": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"oracle": {
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
@@ -145,7 +145,7 @@ exports[`generateModelConfig single native provider uses Claude models with isMa
|
||||
"variant": "max",
|
||||
},
|
||||
"multimodal-looker": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"oracle": {
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
@@ -366,20 +366,20 @@ exports[`generateModelConfig single native provider uses Gemini models when only
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"atlas": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"metis": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"momus": {
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"multimodal-looker": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"oracle": {
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
@@ -389,7 +389,7 @@ exports[`generateModelConfig single native provider uses Gemini models when only
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
},
|
||||
"sisyphus-junior": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
},
|
||||
"categories": {
|
||||
@@ -426,20 +426,20 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"atlas": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"metis": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"momus": {
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"multimodal-looker": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"oracle": {
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
@@ -449,7 +449,7 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
},
|
||||
"sisyphus-junior": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
},
|
||||
"categories": {
|
||||
@@ -465,7 +465,7 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
|
||||
"variant": "high",
|
||||
},
|
||||
"unspecified-high": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"unspecified-low": {
|
||||
"model": "google/gemini-3-flash-preview",
|
||||
@@ -929,7 +929,7 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian whe
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"atlas": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
@@ -938,45 +938,45 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian whe
|
||||
"model": "zai-coding-plan/glm-4.7",
|
||||
},
|
||||
"metis": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"momus": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"multimodal-looker": {
|
||||
"model": "zai-coding-plan/glm-4.6v",
|
||||
},
|
||||
"oracle": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"prometheus": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"sisyphus": {
|
||||
"model": "zai-coding-plan/glm-5",
|
||||
},
|
||||
"sisyphus-junior": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
},
|
||||
"categories": {
|
||||
"quick": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"unspecified-high": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"unspecified-low": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "zai-coding-plan/glm-5",
|
||||
},
|
||||
"writing": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -987,7 +987,7 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian wit
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"atlas": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
@@ -996,45 +996,45 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian wit
|
||||
"model": "zai-coding-plan/glm-4.7",
|
||||
},
|
||||
"metis": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"momus": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"multimodal-looker": {
|
||||
"model": "zai-coding-plan/glm-4.6v",
|
||||
},
|
||||
"oracle": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"prometheus": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"sisyphus": {
|
||||
"model": "zai-coding-plan/glm-5",
|
||||
},
|
||||
"sisyphus-junior": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
},
|
||||
"categories": {
|
||||
"quick": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"unspecified-high": {
|
||||
"model": "zai-coding-plan/glm-5",
|
||||
},
|
||||
"unspecified-low": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "zai-coding-plan/glm-5",
|
||||
},
|
||||
"writing": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1273,7 +1273,7 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
|
||||
"variant": "max",
|
||||
},
|
||||
"multimodal-looker": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"oracle": {
|
||||
"model": "google/gemini-3.1-pro-preview",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs"
|
||||
import type { ConfigMergeResult } from "../types"
|
||||
import { PLUGIN_NAME, LEGACY_PLUGIN_NAME } from "../../shared"
|
||||
import { getConfigDir } from "./config-context"
|
||||
import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists"
|
||||
import { formatErrorWithSuggestion } from "./format-error-with-suggestion"
|
||||
@@ -7,8 +8,6 @@ import { detectConfigFormat } from "./opencode-config-format"
|
||||
import { parseOpenCodeConfigFileWithError, type OpenCodeConfig } from "./parse-opencode-config-file"
|
||||
import { getPluginNameWithVersion } from "./plugin-name-with-version"
|
||||
|
||||
const PACKAGE_NAME = "oh-my-opencode"
|
||||
|
||||
export async function addPluginToOpenCodeConfig(currentVersion: string): Promise<ConfigMergeResult> {
|
||||
try {
|
||||
ensureConfigDirectoryExists()
|
||||
@@ -21,7 +20,7 @@ export async function addPluginToOpenCodeConfig(currentVersion: string): Promise
|
||||
}
|
||||
|
||||
const { format, path } = detectConfigFormat()
|
||||
const pluginEntry = await getPluginNameWithVersion(currentVersion, PACKAGE_NAME)
|
||||
const pluginEntry = await getPluginNameWithVersion(currentVersion, PLUGIN_NAME)
|
||||
|
||||
try {
|
||||
if (format === "none") {
|
||||
@@ -41,13 +40,24 @@ export async function addPluginToOpenCodeConfig(currentVersion: string): Promise
|
||||
|
||||
const config = parseResult.config
|
||||
const plugins = config.plugin ?? []
|
||||
const existingIndex = plugins.findIndex((plugin) => plugin === PACKAGE_NAME || plugin.startsWith(`${PACKAGE_NAME}@`))
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
if (plugins[existingIndex] === pluginEntry) {
|
||||
// Check for existing plugin (either current or legacy name)
|
||||
const currentNameIndex = plugins.findIndex(
|
||||
(plugin) => plugin === PLUGIN_NAME || plugin.startsWith(`${PLUGIN_NAME}@`)
|
||||
)
|
||||
const legacyNameIndex = plugins.findIndex(
|
||||
(plugin) => plugin === LEGACY_PLUGIN_NAME || plugin.startsWith(`${LEGACY_PLUGIN_NAME}@`)
|
||||
)
|
||||
|
||||
// If either name exists, update to new name
|
||||
if (currentNameIndex !== -1) {
|
||||
if (plugins[currentNameIndex] === pluginEntry) {
|
||||
return { success: true, configPath: path }
|
||||
}
|
||||
plugins[existingIndex] = pluginEntry
|
||||
plugins[currentNameIndex] = pluginEntry
|
||||
} else if (legacyNameIndex !== -1) {
|
||||
// Upgrade legacy name to new name
|
||||
plugins[legacyNameIndex] = pluginEntry
|
||||
} else {
|
||||
plugins.push(pluginEntry)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ type BunInstallOutputMode = "inherit" | "pipe"
|
||||
|
||||
interface RunBunInstallOptions {
|
||||
outputMode?: BunInstallOutputMode
|
||||
/** Workspace directory to install to. Defaults to cache dir if not provided. */
|
||||
workspaceDir?: string
|
||||
}
|
||||
|
||||
interface BunInstallOutput {
|
||||
@@ -65,7 +67,7 @@ function logCapturedOutputOnFailure(outputMode: BunInstallOutputMode, output: Bu
|
||||
|
||||
export async function runBunInstallWithDetails(options?: RunBunInstallOptions): Promise<BunInstallResult> {
|
||||
const outputMode = options?.outputMode ?? "pipe"
|
||||
const cacheDir = getOpenCodeCacheDir()
|
||||
const cacheDir = options?.workspaceDir ?? getOpenCodeCacheDir()
|
||||
const packageJsonPath = `${cacheDir}/package.json`
|
||||
|
||||
if (!existsSync(packageJsonPath)) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import { parseJsonc } from "../../shared"
|
||||
import { parseJsonc, LEGACY_PLUGIN_NAME, PLUGIN_NAME } from "../../shared"
|
||||
import type { DetectedConfig } from "../types"
|
||||
import { getOmoConfigPath } from "./config-context"
|
||||
import { detectConfigFormat } from "./opencode-config-format"
|
||||
@@ -55,8 +55,12 @@ function detectProvidersFromOmoConfig(): {
|
||||
}
|
||||
}
|
||||
|
||||
function isOurPlugin(plugin: string): boolean {
|
||||
return plugin === PLUGIN_NAME || plugin.startsWith(`${PLUGIN_NAME}@`) ||
|
||||
plugin === LEGACY_PLUGIN_NAME || plugin.startsWith(`${LEGACY_PLUGIN_NAME}@`)
|
||||
}
|
||||
|
||||
export function detectCurrentConfig(): DetectedConfig {
|
||||
const PACKAGE_NAME = "oh-my-opencode"
|
||||
const result: DetectedConfig = {
|
||||
isInstalled: false,
|
||||
hasClaude: true,
|
||||
@@ -82,7 +86,7 @@ export function detectCurrentConfig(): DetectedConfig {
|
||||
|
||||
const openCodeConfig = parseResult.config
|
||||
const plugins = openCodeConfig.plugin ?? []
|
||||
result.isInstalled = plugins.some((plugin) => plugin.startsWith(PACKAGE_NAME))
|
||||
result.isInstalled = plugins.some(isOurPlugin)
|
||||
|
||||
if (!result.isInstalled) {
|
||||
return result
|
||||
|
||||
@@ -52,6 +52,30 @@ describe("detectCurrentConfig - single package detection", () => {
|
||||
expect(result.isInstalled).toBe(true)
|
||||
})
|
||||
|
||||
it("detects oh-my-openagent as installed (legacy name)", () => {
|
||||
// given
|
||||
const config = { plugin: ["oh-my-openagent"] }
|
||||
writeFileSync(testConfigPath, JSON.stringify(config, null, 2) + "\n", "utf-8")
|
||||
|
||||
// when
|
||||
const result = detectCurrentConfig()
|
||||
|
||||
// then
|
||||
expect(result.isInstalled).toBe(true)
|
||||
})
|
||||
|
||||
it("detects oh-my-openagent with version pin as installed (legacy name)", () => {
|
||||
// given
|
||||
const config = { plugin: ["oh-my-openagent@3.11.0"] }
|
||||
writeFileSync(testConfigPath, JSON.stringify(config, null, 2) + "\n", "utf-8")
|
||||
|
||||
// when
|
||||
const result = detectCurrentConfig()
|
||||
|
||||
// then
|
||||
expect(result.isInstalled).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false when plugin not present", () => {
|
||||
// given
|
||||
const config = { plugin: ["some-other-plugin"] }
|
||||
@@ -64,6 +88,18 @@ describe("detectCurrentConfig - single package detection", () => {
|
||||
expect(result.isInstalled).toBe(false)
|
||||
})
|
||||
|
||||
it("returns false when plugin not present (even with similar name)", () => {
|
||||
// given - not exactly oh-my-openagent
|
||||
const config = { plugin: ["oh-my-openagent-extra"] }
|
||||
writeFileSync(testConfigPath, JSON.stringify(config, null, 2) + "\n", "utf-8")
|
||||
|
||||
// when
|
||||
const result = detectCurrentConfig()
|
||||
|
||||
// then
|
||||
expect(result.isInstalled).toBe(false)
|
||||
})
|
||||
|
||||
it("detects OpenCode Go from the existing omo config", () => {
|
||||
// given
|
||||
writeFileSync(testConfigPath, JSON.stringify({ plugin: ["oh-my-opencode"] }, null, 2) + "\n", "utf-8")
|
||||
@@ -130,6 +166,38 @@ describe("addPluginToOpenCodeConfig - single package writes", () => {
|
||||
expect(savedConfig.plugin).not.toContain("oh-my-opencode@3.10.0")
|
||||
})
|
||||
|
||||
it("recognizes oh-my-openagent as already installed (legacy name)", async () => {
|
||||
// given
|
||||
const config = { plugin: ["oh-my-openagent"] }
|
||||
writeFileSync(testConfigPath, JSON.stringify(config, null, 2) + "\n", "utf-8")
|
||||
|
||||
// when
|
||||
const result = await addPluginToOpenCodeConfig("3.11.0")
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
const savedConfig = JSON.parse(readFileSync(testConfigPath, "utf-8"))
|
||||
// Should upgrade to new name
|
||||
expect(savedConfig.plugin).toContain("oh-my-opencode")
|
||||
expect(savedConfig.plugin).not.toContain("oh-my-openagent")
|
||||
})
|
||||
|
||||
it("replaces version-pinned oh-my-openagent@X.Y.Z with new name", async () => {
|
||||
// given
|
||||
const config = { plugin: ["oh-my-openagent@3.10.0"] }
|
||||
writeFileSync(testConfigPath, JSON.stringify(config, null, 2) + "\n", "utf-8")
|
||||
|
||||
// when
|
||||
const result = await addPluginToOpenCodeConfig("3.11.0")
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
const savedConfig = JSON.parse(readFileSync(testConfigPath, "utf-8"))
|
||||
// Legacy should be replaced with new name
|
||||
expect(savedConfig.plugin).toContain("oh-my-opencode")
|
||||
expect(savedConfig.plugin).not.toContain("oh-my-openagent")
|
||||
})
|
||||
|
||||
it("adds new plugin when none exists", async () => {
|
||||
// given
|
||||
const config = {}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
|
||||
import { PACKAGE_NAME } from "../constants"
|
||||
import { getOpenCodeConfigPaths, parseJsonc } from "../../../shared"
|
||||
import { LEGACY_PLUGIN_NAME, PLUGIN_NAME, getOpenCodeConfigPaths, parseJsonc } from "../../../shared"
|
||||
|
||||
export interface PluginInfo {
|
||||
registered: boolean
|
||||
@@ -24,18 +23,33 @@ function detectConfigPath(): string | null {
|
||||
}
|
||||
|
||||
function parsePluginVersion(entry: string): string | null {
|
||||
if (!entry.startsWith(`${PACKAGE_NAME}@`)) return null
|
||||
const value = entry.slice(PACKAGE_NAME.length + 1)
|
||||
if (!value || value === "latest") return null
|
||||
return value
|
||||
// Check for current package name
|
||||
if (entry.startsWith(`${PLUGIN_NAME}@`)) {
|
||||
const value = entry.slice(PLUGIN_NAME.length + 1)
|
||||
if (!value || value === "latest") return null
|
||||
return value
|
||||
}
|
||||
// Check for legacy package name
|
||||
if (entry.startsWith(`${LEGACY_PLUGIN_NAME}@`)) {
|
||||
const value = entry.slice(LEGACY_PLUGIN_NAME.length + 1)
|
||||
if (!value || value === "latest") return null
|
||||
return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function findPluginEntry(entries: string[]): { entry: string; isLocalDev: boolean } | null {
|
||||
for (const entry of entries) {
|
||||
if (entry === PACKAGE_NAME || entry.startsWith(`${PACKAGE_NAME}@`)) {
|
||||
// Check for current package name
|
||||
if (entry === PLUGIN_NAME || entry.startsWith(`${PLUGIN_NAME}@`)) {
|
||||
return { entry, isLocalDev: false }
|
||||
}
|
||||
if (entry.startsWith("file://") && entry.includes(PACKAGE_NAME)) {
|
||||
// Check for legacy package name
|
||||
if (entry === LEGACY_PLUGIN_NAME || entry.startsWith(`${LEGACY_PLUGIN_NAME}@`)) {
|
||||
return { entry, isLocalDev: false }
|
||||
}
|
||||
// Check for file:// paths that include either name
|
||||
if (entry.startsWith("file://") && (entry.includes(PLUGIN_NAME) || entry.includes(LEGACY_PLUGIN_NAME))) {
|
||||
return { entry, isLocalDev: true }
|
||||
}
|
||||
}
|
||||
@@ -76,7 +90,7 @@ export function getPluginInfo(): PluginInfo {
|
||||
registered: true,
|
||||
configPath,
|
||||
entry: pluginEntry.entry,
|
||||
isPinned: pinnedVersion !== null && /^\d+\.\d+\.\d+/.test(pinnedVersion),
|
||||
isPinned: pinnedVersion !== null && /^\d+\.\d+\.\d+/.test(pinnedVersion ?? ""),
|
||||
pinnedVersion,
|
||||
isLocalDev: pluginEntry.isLocalDev,
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export type { GeneratedOmoConfig } from "./model-fallback-types"
|
||||
|
||||
const ZAI_MODEL = "zai-coding-plan/glm-4.7"
|
||||
|
||||
const ULTIMATE_FALLBACK = "opencode/glm-4.7-free"
|
||||
const ULTIMATE_FALLBACK = "opencode/gpt-5-nano"
|
||||
const SCHEMA_URL = "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json"
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "bun:test"
|
||||
import type { OhMyOpenCodeConfig } from "../../config"
|
||||
import { resolveRunAgent, waitForEventProcessorShutdown } from "./runner"
|
||||
|
||||
@@ -83,7 +83,6 @@ describe("resolveRunAgent", () => {
|
||||
})
|
||||
|
||||
describe("waitForEventProcessorShutdown", () => {
|
||||
|
||||
it("returns quickly when event processor completes", async () => {
|
||||
//#given
|
||||
const eventProcessor = new Promise<void>((resolve) => {
|
||||
@@ -115,3 +114,44 @@ describe("waitForEventProcessorShutdown", () => {
|
||||
expect(elapsed).toBeGreaterThanOrEqual(timeoutMs - 10)
|
||||
})
|
||||
})
|
||||
|
||||
describe("run with invalid model", () => {
|
||||
it("given invalid --model value, when run, then returns exit code 1 with error message", async () => {
|
||||
// given
|
||||
const originalExit = process.exit
|
||||
const originalError = console.error
|
||||
const errorMessages: string[] = []
|
||||
const exitCodes: number[] = []
|
||||
|
||||
console.error = (...args: unknown[]) => {
|
||||
errorMessages.push(args.map(String).join(" "))
|
||||
}
|
||||
process.exit = ((code?: number) => {
|
||||
exitCodes.push(code ?? 0)
|
||||
throw new Error("exit")
|
||||
}) as typeof process.exit
|
||||
|
||||
try {
|
||||
// when
|
||||
// Note: This will actually try to run - but the issue is that resolveRunModel
|
||||
// is called BEFORE the try block, so it throws an unhandled exception
|
||||
// We're testing the runner's error handling
|
||||
const { run } = await import("./runner")
|
||||
|
||||
// This will throw because model "invalid" is invalid format
|
||||
try {
|
||||
await run({
|
||||
message: "test",
|
||||
model: "invalid",
|
||||
})
|
||||
} catch {
|
||||
// Expected to potentially throw due to unhandled model resolution error
|
||||
}
|
||||
} finally {
|
||||
// then - verify error handling
|
||||
// Currently this will fail because the error is not caught properly
|
||||
console.error = originalError
|
||||
process.exit = originalExit
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -47,10 +47,11 @@ export async function run(options: RunOptions): Promise<number> {
|
||||
|
||||
const pluginConfig = loadPluginConfig(directory, { command: "run" })
|
||||
const resolvedAgent = resolveRunAgent(options, pluginConfig)
|
||||
const resolvedModel = resolveRunModel(options.model)
|
||||
const abortController = new AbortController()
|
||||
|
||||
try {
|
||||
const resolvedModel = resolveRunModel(options.model)
|
||||
|
||||
const { client, cleanup: serverCleanup } = await createServerConnection({
|
||||
port: options.port,
|
||||
attach: options.attach,
|
||||
|
||||
59
src/config/schema/background-task-circuit-breaker.test.ts
Normal file
59
src/config/schema/background-task-circuit-breaker.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ZodError } from "zod/v4"
|
||||
import { BackgroundTaskConfigSchema } from "./background-task"
|
||||
|
||||
describe("BackgroundTaskConfigSchema.circuitBreaker", () => {
|
||||
describe("#given valid circuit breaker settings", () => {
|
||||
test("#when parsed #then returns nested config", () => {
|
||||
const result = BackgroundTaskConfigSchema.parse({
|
||||
circuitBreaker: {
|
||||
maxToolCalls: 150,
|
||||
windowSize: 10,
|
||||
repetitionThresholdPercent: 70,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.circuitBreaker).toEqual({
|
||||
maxToolCalls: 150,
|
||||
windowSize: 10,
|
||||
repetitionThresholdPercent: 70,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given windowSize below minimum", () => {
|
||||
test("#when parsed #then throws ZodError", () => {
|
||||
let thrownError: unknown
|
||||
|
||||
try {
|
||||
BackgroundTaskConfigSchema.parse({
|
||||
circuitBreaker: {
|
||||
windowSize: 4,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
thrownError = error
|
||||
}
|
||||
|
||||
expect(thrownError).toBeInstanceOf(ZodError)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given repetitionThresholdPercent is zero", () => {
|
||||
test("#when parsed #then throws ZodError", () => {
|
||||
let thrownError: unknown
|
||||
|
||||
try {
|
||||
BackgroundTaskConfigSchema.parse({
|
||||
circuitBreaker: {
|
||||
repetitionThresholdPercent: 0,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
thrownError = error
|
||||
}
|
||||
|
||||
expect(thrownError).toBeInstanceOf(ZodError)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,11 @@
|
||||
import { z } from "zod"
|
||||
|
||||
const CircuitBreakerConfigSchema = z.object({
|
||||
maxToolCalls: z.number().int().min(10).optional(),
|
||||
windowSize: z.number().int().min(5).optional(),
|
||||
repetitionThresholdPercent: z.number().gt(0).max(100).optional(),
|
||||
})
|
||||
|
||||
export const BackgroundTaskConfigSchema = z.object({
|
||||
defaultConcurrency: z.number().min(1).optional(),
|
||||
providerConcurrency: z.record(z.string(), z.number().min(0)).optional(),
|
||||
@@ -11,6 +17,9 @@ export const BackgroundTaskConfigSchema = z.object({
|
||||
/** Timeout for tasks that never received any progress update, falling back to startedAt (default: 1800000 = 30 minutes, minimum: 60000 = 1 minute) */
|
||||
messageStalenessTimeoutMs: z.number().min(60000).optional(),
|
||||
syncPollTimeoutMs: z.number().min(60000).optional(),
|
||||
/** Maximum tool calls per subagent task before circuit breaker triggers (default: 200, minimum: 10). Prevents runaway loops from burning unlimited tokens. */
|
||||
maxToolCalls: z.number().int().min(10).optional(),
|
||||
circuitBreaker: CircuitBreakerConfigSchema.optional(),
|
||||
})
|
||||
|
||||
export type BackgroundTaskConfig = z.infer<typeof BackgroundTaskConfigSchema>
|
||||
|
||||
@@ -51,7 +51,6 @@ export const HookNameSchema = z.enum([
|
||||
"anthropic-effort",
|
||||
"hashline-read-enhancer",
|
||||
"read-image-resizer",
|
||||
"openclaw-sender",
|
||||
])
|
||||
|
||||
export type HookName = z.infer<typeof HookNameSchema>
|
||||
|
||||
@@ -12,7 +12,6 @@ import { BuiltinCommandNameSchema } from "./commands"
|
||||
import { ExperimentalConfigSchema } from "./experimental"
|
||||
import { GitMasterConfigSchema } from "./git-master"
|
||||
import { NotificationConfigSchema } from "./notification"
|
||||
import { OpenClawConfigSchema } from "./openclaw"
|
||||
import { RalphLoopConfigSchema } from "./ralph-loop"
|
||||
import { RuntimeFallbackConfigSchema } from "./runtime-fallback"
|
||||
import { SkillsConfigSchema } from "./skills"
|
||||
@@ -56,7 +55,6 @@ export const OhMyOpenCodeConfigSchema = z.object({
|
||||
runtime_fallback: z.union([z.boolean(), RuntimeFallbackConfigSchema]).optional(),
|
||||
background_task: BackgroundTaskConfigSchema.optional(),
|
||||
notification: NotificationConfigSchema.optional(),
|
||||
openclaw: OpenClawConfigSchema.optional(),
|
||||
babysitting: BabysittingConfigSchema.optional(),
|
||||
git_master: GitMasterConfigSchema.optional(),
|
||||
browser_automation_engine: BrowserAutomationConfigSchema.optional(),
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const OpenClawHookEventSchema = z.enum([
|
||||
"session-start",
|
||||
"session-end",
|
||||
"session-idle",
|
||||
"ask-user-question",
|
||||
"stop",
|
||||
]);
|
||||
|
||||
export const OpenClawHttpGatewayConfigSchema = z.object({
|
||||
type: z.literal("http").optional(),
|
||||
url: z.string(), // Allow looser URL validation as it might contain placeholders
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
method: z.enum(["POST", "PUT"]).optional(),
|
||||
timeout: z.number().optional(),
|
||||
});
|
||||
|
||||
export const OpenClawCommandGatewayConfigSchema = z.object({
|
||||
type: z.literal("command"),
|
||||
command: z.string(),
|
||||
timeout: z.number().optional(),
|
||||
});
|
||||
|
||||
export const OpenClawGatewayConfigSchema = z.union([
|
||||
OpenClawHttpGatewayConfigSchema,
|
||||
OpenClawCommandGatewayConfigSchema,
|
||||
]);
|
||||
|
||||
export const OpenClawHookMappingSchema = z.object({
|
||||
gateway: z.string(),
|
||||
instruction: z.string(),
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
export const OpenClawConfigSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
gateways: z.record(z.string(), OpenClawGatewayConfigSchema),
|
||||
hooks: z
|
||||
.object({
|
||||
"session-start": OpenClawHookMappingSchema.optional(),
|
||||
"session-end": OpenClawHookMappingSchema.optional(),
|
||||
"session-idle": OpenClawHookMappingSchema.optional(),
|
||||
"ask-user-question": OpenClawHookMappingSchema.optional(),
|
||||
stop: OpenClawHookMappingSchema.optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type OpenClawConfig = z.infer<typeof OpenClawConfigSchema>;
|
||||
@@ -2,9 +2,13 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { BackgroundTask, LaunchInput } from "./types"
|
||||
|
||||
export const TASK_TTL_MS = 30 * 60 * 1000
|
||||
export const TERMINAL_TASK_TTL_MS = 30 * 60 * 1000
|
||||
export const MIN_STABILITY_TIME_MS = 10 * 1000
|
||||
export const DEFAULT_STALE_TIMEOUT_MS = 180_000
|
||||
export const DEFAULT_STALE_TIMEOUT_MS = 1_200_000
|
||||
export const DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS = 1_800_000
|
||||
export const DEFAULT_MAX_TOOL_CALLS = 200
|
||||
export const DEFAULT_CIRCUIT_BREAKER_WINDOW_SIZE = 20
|
||||
export const DEFAULT_CIRCUIT_BREAKER_REPETITION_THRESHOLD_PERCENT = 80
|
||||
export const MIN_RUNTIME_BEFORE_STALE_MS = 30_000
|
||||
export const MIN_IDLE_TIME_MS = 5000
|
||||
export const POLLING_INTERVAL_MS = 3000
|
||||
|
||||
17
src/features/background-agent/default-stale-timeout.test.ts
Normal file
17
src/features/background-agent/default-stale-timeout.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
declare const require: (name: string) => any
|
||||
const { describe, expect, test } = require("bun:test")
|
||||
|
||||
import { DEFAULT_STALE_TIMEOUT_MS } from "./constants"
|
||||
|
||||
describe("DEFAULT_STALE_TIMEOUT_MS", () => {
|
||||
test("uses a 20 minute default", () => {
|
||||
// #given
|
||||
const expectedTimeout = 20 * 60 * 1000
|
||||
|
||||
// #when
|
||||
const timeout = DEFAULT_STALE_TIMEOUT_MS
|
||||
|
||||
// #then
|
||||
expect(timeout).toBe(expectedTimeout)
|
||||
})
|
||||
})
|
||||
117
src/features/background-agent/loop-detector.test.ts
Normal file
117
src/features/background-agent/loop-detector.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
detectRepetitiveToolUse,
|
||||
recordToolCall,
|
||||
resolveCircuitBreakerSettings,
|
||||
} from "./loop-detector"
|
||||
|
||||
function buildWindow(
|
||||
toolNames: string[],
|
||||
override?: Parameters<typeof resolveCircuitBreakerSettings>[0]
|
||||
) {
|
||||
const settings = resolveCircuitBreakerSettings(override)
|
||||
|
||||
return toolNames.reduce(
|
||||
(window, toolName) => recordToolCall(window, toolName, settings),
|
||||
undefined as ReturnType<typeof recordToolCall> | undefined
|
||||
)
|
||||
}
|
||||
|
||||
describe("loop-detector", () => {
|
||||
describe("resolveCircuitBreakerSettings", () => {
|
||||
describe("#given nested circuit breaker config", () => {
|
||||
test("#when resolved #then nested values override defaults", () => {
|
||||
const result = resolveCircuitBreakerSettings({
|
||||
maxToolCalls: 200,
|
||||
circuitBreaker: {
|
||||
maxToolCalls: 120,
|
||||
windowSize: 10,
|
||||
repetitionThresholdPercent: 70,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
maxToolCalls: 120,
|
||||
windowSize: 10,
|
||||
repetitionThresholdPercent: 70,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("detectRepetitiveToolUse", () => {
|
||||
describe("#given recent tools are diverse", () => {
|
||||
test("#when evaluated #then it does not trigger", () => {
|
||||
const window = buildWindow([
|
||||
"read",
|
||||
"grep",
|
||||
"edit",
|
||||
"bash",
|
||||
"read",
|
||||
"glob",
|
||||
"lsp_diagnostics",
|
||||
"read",
|
||||
"grep",
|
||||
"edit",
|
||||
])
|
||||
|
||||
const result = detectRepetitiveToolUse(window)
|
||||
|
||||
expect(result.triggered).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given the same tool dominates the recent window", () => {
|
||||
test("#when evaluated #then it triggers", () => {
|
||||
const window = buildWindow([
|
||||
"read",
|
||||
"read",
|
||||
"read",
|
||||
"edit",
|
||||
"read",
|
||||
"read",
|
||||
"read",
|
||||
"read",
|
||||
"grep",
|
||||
"read",
|
||||
], {
|
||||
circuitBreaker: {
|
||||
windowSize: 10,
|
||||
repetitionThresholdPercent: 80,
|
||||
},
|
||||
})
|
||||
|
||||
const result = detectRepetitiveToolUse(window)
|
||||
|
||||
expect(result).toEqual({
|
||||
triggered: true,
|
||||
toolName: "read",
|
||||
repeatedCount: 8,
|
||||
sampleSize: 10,
|
||||
thresholdPercent: 80,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given the window is not full yet", () => {
|
||||
test("#when the current sample crosses the threshold #then it still triggers", () => {
|
||||
const window = buildWindow(["read", "read", "edit", "read", "read", "read", "read", "read"], {
|
||||
circuitBreaker: {
|
||||
windowSize: 10,
|
||||
repetitionThresholdPercent: 80,
|
||||
},
|
||||
})
|
||||
|
||||
const result = detectRepetitiveToolUse(window)
|
||||
|
||||
expect(result).toEqual({
|
||||
triggered: true,
|
||||
toolName: "read",
|
||||
repeatedCount: 7,
|
||||
sampleSize: 8,
|
||||
thresholdPercent: 80,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
96
src/features/background-agent/loop-detector.ts
Normal file
96
src/features/background-agent/loop-detector.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { BackgroundTaskConfig } from "../../config/schema"
|
||||
import {
|
||||
DEFAULT_CIRCUIT_BREAKER_REPETITION_THRESHOLD_PERCENT,
|
||||
DEFAULT_CIRCUIT_BREAKER_WINDOW_SIZE,
|
||||
DEFAULT_MAX_TOOL_CALLS,
|
||||
} from "./constants"
|
||||
import type { ToolCallWindow } from "./types"
|
||||
|
||||
export interface CircuitBreakerSettings {
|
||||
maxToolCalls: number
|
||||
windowSize: number
|
||||
repetitionThresholdPercent: number
|
||||
}
|
||||
|
||||
export interface ToolLoopDetectionResult {
|
||||
triggered: boolean
|
||||
toolName?: string
|
||||
repeatedCount?: number
|
||||
sampleSize?: number
|
||||
thresholdPercent?: number
|
||||
}
|
||||
|
||||
export function resolveCircuitBreakerSettings(
|
||||
config?: BackgroundTaskConfig
|
||||
): CircuitBreakerSettings {
|
||||
return {
|
||||
maxToolCalls:
|
||||
config?.circuitBreaker?.maxToolCalls ?? config?.maxToolCalls ?? DEFAULT_MAX_TOOL_CALLS,
|
||||
windowSize: config?.circuitBreaker?.windowSize ?? DEFAULT_CIRCUIT_BREAKER_WINDOW_SIZE,
|
||||
repetitionThresholdPercent:
|
||||
config?.circuitBreaker?.repetitionThresholdPercent ??
|
||||
DEFAULT_CIRCUIT_BREAKER_REPETITION_THRESHOLD_PERCENT,
|
||||
}
|
||||
}
|
||||
|
||||
export function recordToolCall(
|
||||
window: ToolCallWindow | undefined,
|
||||
toolName: string,
|
||||
settings: CircuitBreakerSettings
|
||||
): ToolCallWindow {
|
||||
const previous = window?.toolNames ?? []
|
||||
const toolNames = [...previous, toolName].slice(-settings.windowSize)
|
||||
|
||||
return {
|
||||
toolNames,
|
||||
windowSize: settings.windowSize,
|
||||
thresholdPercent: settings.repetitionThresholdPercent,
|
||||
}
|
||||
}
|
||||
|
||||
export function detectRepetitiveToolUse(
|
||||
window: ToolCallWindow | undefined
|
||||
): ToolLoopDetectionResult {
|
||||
if (!window || window.toolNames.length === 0) {
|
||||
return { triggered: false }
|
||||
}
|
||||
|
||||
const counts = new Map<string, number>()
|
||||
for (const toolName of window.toolNames) {
|
||||
counts.set(toolName, (counts.get(toolName) ?? 0) + 1)
|
||||
}
|
||||
|
||||
let repeatedTool: string | undefined
|
||||
let repeatedCount = 0
|
||||
|
||||
for (const [toolName, count] of counts.entries()) {
|
||||
if (count > repeatedCount) {
|
||||
repeatedTool = toolName
|
||||
repeatedCount = count
|
||||
}
|
||||
}
|
||||
|
||||
const sampleSize = window.toolNames.length
|
||||
const minimumSampleSize = Math.min(
|
||||
window.windowSize,
|
||||
Math.ceil((window.windowSize * window.thresholdPercent) / 100)
|
||||
)
|
||||
|
||||
if (sampleSize < minimumSampleSize) {
|
||||
return { triggered: false }
|
||||
}
|
||||
|
||||
const thresholdCount = Math.ceil((sampleSize * window.thresholdPercent) / 100)
|
||||
|
||||
if (!repeatedTool || repeatedCount < thresholdCount) {
|
||||
return { triggered: false }
|
||||
}
|
||||
|
||||
return {
|
||||
triggered: true,
|
||||
toolName: repeatedTool,
|
||||
repeatedCount,
|
||||
sampleSize,
|
||||
thresholdPercent: window.thresholdPercent,
|
||||
}
|
||||
}
|
||||
239
src/features/background-agent/manager-circuit-breaker.test.ts
Normal file
239
src/features/background-agent/manager-circuit-breaker.test.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { tmpdir } from "node:os"
|
||||
import type { BackgroundTaskConfig } from "../../config/schema"
|
||||
import { BackgroundManager } from "./manager"
|
||||
import type { BackgroundTask } from "./types"
|
||||
|
||||
function createManager(config?: BackgroundTaskConfig): BackgroundManager {
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async () => ({}),
|
||||
promptAsync: async () => ({}),
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
|
||||
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, config)
|
||||
const testManager = manager as unknown as {
|
||||
enqueueNotificationForParent: (sessionID: string, fn: () => Promise<void>) => Promise<void>
|
||||
notifyParentSession: (task: BackgroundTask) => Promise<void>
|
||||
tasks: Map<string, BackgroundTask>
|
||||
}
|
||||
|
||||
testManager.enqueueNotificationForParent = async (_sessionID, fn) => {
|
||||
await fn()
|
||||
}
|
||||
testManager.notifyParentSession = async () => {}
|
||||
|
||||
return manager
|
||||
}
|
||||
|
||||
function getTaskMap(manager: BackgroundManager): Map<string, BackgroundTask> {
|
||||
return (manager as unknown as { tasks: Map<string, BackgroundTask> }).tasks
|
||||
}
|
||||
|
||||
async function flushAsyncWork() {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
describe("BackgroundManager circuit breaker", () => {
|
||||
describe("#given the same tool dominates the recent window", () => {
|
||||
test("#when tool events arrive #then the task is cancelled early", async () => {
|
||||
const manager = createManager({
|
||||
circuitBreaker: {
|
||||
windowSize: 20,
|
||||
repetitionThresholdPercent: 80,
|
||||
},
|
||||
})
|
||||
const task: BackgroundTask = {
|
||||
id: "task-loop-1",
|
||||
sessionID: "session-loop-1",
|
||||
parentSessionID: "parent-1",
|
||||
parentMessageID: "msg-1",
|
||||
description: "Looping task",
|
||||
prompt: "loop",
|
||||
agent: "explore",
|
||||
status: "running",
|
||||
startedAt: new Date(Date.now() - 60_000),
|
||||
progress: {
|
||||
toolCalls: 0,
|
||||
lastUpdate: new Date(Date.now() - 60_000),
|
||||
},
|
||||
}
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
for (const toolName of [
|
||||
"read",
|
||||
"read",
|
||||
"grep",
|
||||
"read",
|
||||
"edit",
|
||||
"read",
|
||||
"read",
|
||||
"bash",
|
||||
"read",
|
||||
"read",
|
||||
"read",
|
||||
"glob",
|
||||
"read",
|
||||
"read",
|
||||
"read",
|
||||
"read",
|
||||
"read",
|
||||
"read",
|
||||
"read",
|
||||
"read",
|
||||
]) {
|
||||
manager.handleEvent({
|
||||
type: "message.part.updated",
|
||||
properties: { sessionID: task.sessionID, type: "tool", tool: toolName },
|
||||
})
|
||||
}
|
||||
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(task.status).toBe("cancelled")
|
||||
expect(task.error).toContain("repeatedly called read 16/20 times")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given recent tool calls are diverse", () => {
|
||||
test("#when the window fills #then the task keeps running", async () => {
|
||||
const manager = createManager({
|
||||
circuitBreaker: {
|
||||
windowSize: 10,
|
||||
repetitionThresholdPercent: 80,
|
||||
},
|
||||
})
|
||||
const task: BackgroundTask = {
|
||||
id: "task-diverse-1",
|
||||
sessionID: "session-diverse-1",
|
||||
parentSessionID: "parent-1",
|
||||
parentMessageID: "msg-1",
|
||||
description: "Healthy task",
|
||||
prompt: "work",
|
||||
agent: "explore",
|
||||
status: "running",
|
||||
startedAt: new Date(Date.now() - 60_000),
|
||||
progress: {
|
||||
toolCalls: 0,
|
||||
lastUpdate: new Date(Date.now() - 60_000),
|
||||
},
|
||||
}
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
for (const toolName of [
|
||||
"read",
|
||||
"grep",
|
||||
"edit",
|
||||
"bash",
|
||||
"glob",
|
||||
"read",
|
||||
"lsp_diagnostics",
|
||||
"grep",
|
||||
"edit",
|
||||
"read",
|
||||
]) {
|
||||
manager.handleEvent({
|
||||
type: "message.part.updated",
|
||||
properties: { sessionID: task.sessionID, type: "tool", tool: toolName },
|
||||
})
|
||||
}
|
||||
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(task.status).toBe("running")
|
||||
expect(task.progress?.toolCalls).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given the absolute cap is configured lower than the repetition detector needs", () => {
|
||||
test("#when the raw tool-call cap is reached #then the backstop still cancels the task", async () => {
|
||||
const manager = createManager({
|
||||
maxToolCalls: 3,
|
||||
circuitBreaker: {
|
||||
windowSize: 10,
|
||||
repetitionThresholdPercent: 95,
|
||||
},
|
||||
})
|
||||
const task: BackgroundTask = {
|
||||
id: "task-cap-1",
|
||||
sessionID: "session-cap-1",
|
||||
parentSessionID: "parent-1",
|
||||
parentMessageID: "msg-1",
|
||||
description: "Backstop task",
|
||||
prompt: "work",
|
||||
agent: "explore",
|
||||
status: "running",
|
||||
startedAt: new Date(Date.now() - 60_000),
|
||||
progress: {
|
||||
toolCalls: 0,
|
||||
lastUpdate: new Date(Date.now() - 60_000),
|
||||
},
|
||||
}
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
for (const toolName of ["read", "grep", "edit"]) {
|
||||
manager.handleEvent({
|
||||
type: "message.part.updated",
|
||||
properties: { sessionID: task.sessionID, type: "tool", tool: toolName },
|
||||
})
|
||||
}
|
||||
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(task.status).toBe("cancelled")
|
||||
expect(task.error).toContain("maximum tool call limit (3)")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given the same running tool part emits multiple updates", () => {
|
||||
test("#when duplicate running updates arrive #then it only counts the tool once", async () => {
|
||||
const manager = createManager({
|
||||
maxToolCalls: 2,
|
||||
circuitBreaker: {
|
||||
windowSize: 5,
|
||||
repetitionThresholdPercent: 80,
|
||||
},
|
||||
})
|
||||
const task: BackgroundTask = {
|
||||
id: "task-dedupe-1",
|
||||
sessionID: "session-dedupe-1",
|
||||
parentSessionID: "parent-1",
|
||||
parentMessageID: "msg-1",
|
||||
description: "Dedupe task",
|
||||
prompt: "work",
|
||||
agent: "explore",
|
||||
status: "running",
|
||||
startedAt: new Date(Date.now() - 60_000),
|
||||
progress: {
|
||||
toolCalls: 0,
|
||||
lastUpdate: new Date(Date.now() - 60_000),
|
||||
},
|
||||
}
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
manager.handleEvent({
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "tool-1",
|
||||
sessionID: task.sessionID,
|
||||
type: "tool",
|
||||
tool: "bash",
|
||||
state: { status: "running" },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(task.status).toBe("running")
|
||||
expect(task.progress?.toolCalls).toBe(1)
|
||||
expect(task.progress?.countedToolPartIDs).toEqual(["tool-1"])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3027,10 +3027,10 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
|
||||
prompt: "Test",
|
||||
agent: "test-agent",
|
||||
status: "running",
|
||||
startedAt: new Date(Date.now() - 300_000),
|
||||
startedAt: new Date(Date.now() - 25 * 60 * 1000),
|
||||
progress: {
|
||||
toolCalls: 1,
|
||||
lastUpdate: new Date(Date.now() - 200_000),
|
||||
lastUpdate: new Date(Date.now() - 21 * 60 * 1000),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import {
|
||||
POLLING_INTERVAL_MS,
|
||||
TASK_CLEANUP_DELAY_MS,
|
||||
TASK_TTL_MS,
|
||||
} from "./constants"
|
||||
|
||||
import { subagentSessions } from "../claude-code-session-state"
|
||||
@@ -51,6 +52,11 @@ import { join } from "node:path"
|
||||
import { pruneStaleTasksAndNotifications } from "./task-poller"
|
||||
import { checkAndInterruptStaleTasks } from "./task-poller"
|
||||
import { removeTaskToastTracking } from "./remove-task-toast-tracking"
|
||||
import {
|
||||
detectRepetitiveToolUse,
|
||||
recordToolCall,
|
||||
resolveCircuitBreakerSettings,
|
||||
} from "./loop-detector"
|
||||
import {
|
||||
createSubagentDepthLimitError,
|
||||
createSubagentDescendantLimitError,
|
||||
@@ -64,9 +70,11 @@ type OpencodeClient = PluginInput["client"]
|
||||
|
||||
|
||||
interface MessagePartInfo {
|
||||
id?: string
|
||||
sessionID?: string
|
||||
type?: string
|
||||
tool?: string
|
||||
state?: { status?: string }
|
||||
}
|
||||
|
||||
interface EventProperties {
|
||||
@@ -80,6 +88,19 @@ interface Event {
|
||||
properties?: EventProperties
|
||||
}
|
||||
|
||||
function resolveMessagePartInfo(properties: EventProperties | undefined): MessagePartInfo | undefined {
|
||||
if (!properties || typeof properties !== "object") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const nestedPart = properties.part
|
||||
if (nestedPart && typeof nestedPart === "object") {
|
||||
return nestedPart as MessagePartInfo
|
||||
}
|
||||
|
||||
return properties as MessagePartInfo
|
||||
}
|
||||
|
||||
interface Todo {
|
||||
content: string
|
||||
status: string
|
||||
@@ -100,6 +121,8 @@ export interface SubagentSessionCreatedEvent {
|
||||
|
||||
export type OnSubagentSessionCreated = (event: SubagentSessionCreatedEvent) => Promise<void>
|
||||
|
||||
const MAX_TASK_REMOVAL_RESCHEDULES = 6
|
||||
|
||||
export class BackgroundManager {
|
||||
|
||||
|
||||
@@ -720,6 +743,8 @@ export class BackgroundManager {
|
||||
|
||||
existingTask.progress = {
|
||||
toolCalls: existingTask.progress?.toolCalls ?? 0,
|
||||
toolCallWindow: existingTask.progress?.toolCallWindow,
|
||||
countedToolPartIDs: existingTask.progress?.countedToolPartIDs,
|
||||
lastUpdate: new Date(),
|
||||
}
|
||||
|
||||
@@ -852,8 +877,7 @@ export class BackgroundManager {
|
||||
}
|
||||
|
||||
if (event.type === "message.part.updated" || event.type === "message.part.delta") {
|
||||
if (!props || typeof props !== "object" || !("sessionID" in props)) return
|
||||
const partInfo = props as unknown as MessagePartInfo
|
||||
const partInfo = resolveMessagePartInfo(props)
|
||||
const sessionID = partInfo?.sessionID
|
||||
if (!sessionID) return
|
||||
|
||||
@@ -876,8 +900,63 @@ export class BackgroundManager {
|
||||
task.progress.lastUpdate = new Date()
|
||||
|
||||
if (partInfo?.type === "tool" || partInfo?.tool) {
|
||||
const countedToolPartIDs = task.progress.countedToolPartIDs ?? []
|
||||
const shouldCountToolCall =
|
||||
!partInfo.id ||
|
||||
partInfo.state?.status !== "running" ||
|
||||
!countedToolPartIDs.includes(partInfo.id)
|
||||
|
||||
if (!shouldCountToolCall) {
|
||||
return
|
||||
}
|
||||
|
||||
if (partInfo.id && partInfo.state?.status === "running") {
|
||||
task.progress.countedToolPartIDs = [...countedToolPartIDs, partInfo.id]
|
||||
}
|
||||
|
||||
task.progress.toolCalls += 1
|
||||
task.progress.lastTool = partInfo.tool
|
||||
const circuitBreaker = resolveCircuitBreakerSettings(this.config)
|
||||
if (partInfo.tool) {
|
||||
task.progress.toolCallWindow = recordToolCall(
|
||||
task.progress.toolCallWindow,
|
||||
partInfo.tool,
|
||||
circuitBreaker
|
||||
)
|
||||
|
||||
const loopDetection = detectRepetitiveToolUse(task.progress.toolCallWindow)
|
||||
if (loopDetection.triggered) {
|
||||
log("[background-agent] Circuit breaker: repetitive tool usage detected", {
|
||||
taskId: task.id,
|
||||
agent: task.agent,
|
||||
sessionID,
|
||||
toolName: loopDetection.toolName,
|
||||
repeatedCount: loopDetection.repeatedCount,
|
||||
sampleSize: loopDetection.sampleSize,
|
||||
thresholdPercent: loopDetection.thresholdPercent,
|
||||
})
|
||||
void this.cancelTask(task.id, {
|
||||
source: "circuit-breaker",
|
||||
reason: `Subagent repeatedly called ${loopDetection.toolName} ${loopDetection.repeatedCount}/${loopDetection.sampleSize} times in the recent tool-call window (${loopDetection.thresholdPercent}% threshold). This usually indicates an infinite loop. The task was automatically cancelled to prevent excessive token usage.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const maxToolCalls = circuitBreaker.maxToolCalls
|
||||
if (task.progress.toolCalls >= maxToolCalls) {
|
||||
log("[background-agent] Circuit breaker: tool call limit reached", {
|
||||
taskId: task.id,
|
||||
toolCalls: task.progress.toolCalls,
|
||||
maxToolCalls,
|
||||
agent: task.agent,
|
||||
sessionID,
|
||||
})
|
||||
void this.cancelTask(task.id, {
|
||||
source: "circuit-breaker",
|
||||
reason: `Subagent exceeded maximum tool call limit (${maxToolCalls}). This usually indicates an infinite loop. The task was automatically cancelled to prevent excessive token usage.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1188,7 +1267,7 @@ export class BackgroundManager {
|
||||
this.completedTaskSummaries.delete(parentSessionID)
|
||||
}
|
||||
|
||||
private scheduleTaskRemoval(taskId: string): void {
|
||||
private scheduleTaskRemoval(taskId: string, rescheduleCount = 0): void {
|
||||
const existingTimer = this.completionTimers.get(taskId)
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer)
|
||||
@@ -1198,17 +1277,29 @@ export class BackgroundManager {
|
||||
const timer = setTimeout(() => {
|
||||
this.completionTimers.delete(taskId)
|
||||
const task = this.tasks.get(taskId)
|
||||
if (task) {
|
||||
this.clearNotificationsForTask(taskId)
|
||||
this.tasks.delete(taskId)
|
||||
this.clearTaskHistoryWhenParentTasksGone(task.parentSessionID)
|
||||
if (task.sessionID) {
|
||||
subagentSessions.delete(task.sessionID)
|
||||
SessionCategoryRegistry.remove(task.sessionID)
|
||||
if (!task) return
|
||||
|
||||
if (task.parentSessionID) {
|
||||
const siblings = this.getTasksByParentSession(task.parentSessionID)
|
||||
const runningOrPendingSiblings = siblings.filter(
|
||||
sibling => sibling.id !== taskId && (sibling.status === "running" || sibling.status === "pending"),
|
||||
)
|
||||
const completedAtTimestamp = task.completedAt?.getTime()
|
||||
const reachedTaskTtl = completedAtTimestamp !== undefined && (Date.now() - completedAtTimestamp) >= TASK_TTL_MS
|
||||
if (runningOrPendingSiblings.length > 0 && rescheduleCount < MAX_TASK_REMOVAL_RESCHEDULES && !reachedTaskTtl) {
|
||||
this.scheduleTaskRemoval(taskId, rescheduleCount + 1)
|
||||
return
|
||||
}
|
||||
log("[background-agent] Removed completed task from memory:", taskId)
|
||||
this.clearTaskHistoryWhenParentTasksGone(task?.parentSessionID)
|
||||
}
|
||||
|
||||
this.clearNotificationsForTask(taskId)
|
||||
this.tasks.delete(taskId)
|
||||
this.clearTaskHistoryWhenParentTasksGone(task.parentSessionID)
|
||||
if (task.sessionID) {
|
||||
subagentSessions.delete(task.sessionID)
|
||||
SessionCategoryRegistry.remove(task.sessionID)
|
||||
}
|
||||
log("[background-agent] Removed completed task from memory:", taskId)
|
||||
}, TASK_CLEANUP_DELAY_MS)
|
||||
|
||||
this.completionTimers.set(taskId, timer)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
declare const require: (name: string) => any
|
||||
const { describe, test, expect, afterEach } = require("bun:test")
|
||||
import { tmpdir } from "node:os"
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { TASK_CLEANUP_DELAY_MS } from "./constants"
|
||||
import { BackgroundManager } from "./manager"
|
||||
@@ -157,17 +156,19 @@ function getRequiredTimer(manager: BackgroundManager, taskID: string): ReturnTyp
|
||||
}
|
||||
|
||||
describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
|
||||
describe("#given 2 tasks for same parent and task A completed", () => {
|
||||
test("#when task B is still running #then task A is cleaned up from this.tasks after delay even though task B is not done", async () => {
|
||||
describe("#given 3 tasks for same parent and task A completed first", () => {
|
||||
test("#when siblings are still running or pending #then task A remains until siblings also complete", async () => {
|
||||
// given
|
||||
const { manager } = createManager(false)
|
||||
managerUnderTest = manager
|
||||
fakeTimers = installFakeTimers()
|
||||
const taskA = createTask({ id: "task-a", parentSessionID: "parent-1", description: "task A", status: "completed", completedAt: new Date("2026-03-11T00:01:00.000Z") })
|
||||
const taskA = createTask({ id: "task-a", parentSessionID: "parent-1", description: "task A", status: "completed", completedAt: new Date() })
|
||||
const taskB = createTask({ id: "task-b", parentSessionID: "parent-1", description: "task B", status: "running" })
|
||||
const taskC = createTask({ id: "task-c", parentSessionID: "parent-1", description: "task C", status: "pending" })
|
||||
getTasks(manager).set(taskA.id, taskA)
|
||||
getTasks(manager).set(taskB.id, taskB)
|
||||
getPendingByParent(manager).set(taskA.parentSessionID, new Set([taskA.id, taskB.id]))
|
||||
getTasks(manager).set(taskC.id, taskC)
|
||||
getPendingByParent(manager).set(taskA.parentSessionID, new Set([taskA.id, taskB.id, taskC.id]))
|
||||
|
||||
// when
|
||||
await notifyParentSessionForTest(manager, taskA)
|
||||
@@ -177,8 +178,23 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
|
||||
|
||||
// then
|
||||
expect(fakeTimers.getDelay(taskATimer)).toBeUndefined()
|
||||
expect(getTasks(manager).has(taskA.id)).toBe(false)
|
||||
expect(getTasks(manager).has(taskA.id)).toBe(true)
|
||||
expect(getTasks(manager).get(taskB.id)).toBe(taskB)
|
||||
expect(getTasks(manager).get(taskC.id)).toBe(taskC)
|
||||
|
||||
// when
|
||||
taskB.status = "completed"
|
||||
taskB.completedAt = new Date()
|
||||
taskC.status = "completed"
|
||||
taskC.completedAt = new Date()
|
||||
await notifyParentSessionForTest(manager, taskB)
|
||||
await notifyParentSessionForTest(manager, taskC)
|
||||
const rescheduledTaskATimer = getRequiredTimer(manager, taskA.id)
|
||||
expect(fakeTimers.getDelay(rescheduledTaskATimer)).toBe(TASK_CLEANUP_DELAY_MS)
|
||||
fakeTimers.run(rescheduledTaskATimer)
|
||||
|
||||
// then
|
||||
expect(getTasks(manager).has(taskA.id)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -9,12 +9,11 @@ import {
|
||||
DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS,
|
||||
DEFAULT_STALE_TIMEOUT_MS,
|
||||
MIN_RUNTIME_BEFORE_STALE_MS,
|
||||
TERMINAL_TASK_TTL_MS,
|
||||
TASK_TTL_MS,
|
||||
} from "./constants"
|
||||
import { removeTaskToastTracking } from "./remove-task-toast-tracking"
|
||||
|
||||
const TERMINAL_TASK_TTL_MS = 30 * 60 * 1000
|
||||
|
||||
const TERMINAL_TASK_STATUSES = new Set<BackgroundTask["status"]>([
|
||||
"completed",
|
||||
"error",
|
||||
|
||||
@@ -9,9 +9,17 @@ export type BackgroundTaskStatus =
|
||||
| "cancelled"
|
||||
| "interrupt"
|
||||
|
||||
export interface ToolCallWindow {
|
||||
toolNames: string[]
|
||||
windowSize: number
|
||||
thresholdPercent: number
|
||||
}
|
||||
|
||||
export interface TaskProgress {
|
||||
toolCalls: number
|
||||
lastTool?: string
|
||||
toolCallWindow?: ToolCallWindow
|
||||
countedToolPartIDs?: string[]
|
||||
lastUpdate: Date
|
||||
lastMessage?: string
|
||||
lastMessageAt?: Date
|
||||
|
||||
@@ -59,10 +59,13 @@ export function appendSessionId(directory: string, sessionId: string): BoulderSt
|
||||
if (!Array.isArray(state.session_ids)) {
|
||||
state.session_ids = []
|
||||
}
|
||||
const originalSessionIds = [...state.session_ids]
|
||||
state.session_ids.push(sessionId)
|
||||
if (writeBoulderState(directory, state)) {
|
||||
return state
|
||||
}
|
||||
state.session_ids = originalSessionIds
|
||||
return null
|
||||
}
|
||||
|
||||
return state
|
||||
|
||||
@@ -153,3 +153,25 @@ describe("#given git_env_prefix with commit footer", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given idempotency of prefixGitCommandsInBashCodeBlocks", () => {
|
||||
describe("#when git_env_prefix is provided and template already has prefixed commands in env prefix section", () => {
|
||||
it("#then does NOT double-prefix the already-prefixed commands", () => {
|
||||
const result = injectGitMasterConfig(SAMPLE_TEMPLATE, {
|
||||
commit_footer: false,
|
||||
include_co_authored_by: false,
|
||||
git_env_prefix: "GIT_MASTER=1",
|
||||
})
|
||||
|
||||
expect(result).not.toContain("GIT_MASTER=1 GIT_MASTER=1 git status")
|
||||
expect(result).not.toContain("GIT_MASTER=1 GIT_MASTER=1 git add")
|
||||
expect(result).not.toContain("GIT_MASTER=1 GIT_MASTER=1 git commit")
|
||||
expect(result).not.toContain("GIT_MASTER=1 GIT_MASTER=1 git push")
|
||||
|
||||
expect(result).toContain("GIT_MASTER=1 git status")
|
||||
expect(result).toContain("GIT_MASTER=1 git add")
|
||||
expect(result).toContain("GIT_MASTER=1 git commit")
|
||||
expect(result).toContain("GIT_MASTER=1 git push")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -72,8 +72,16 @@ function prefixGitCommandsInBashCodeBlocks(template: string, prefix: string): st
|
||||
|
||||
function prefixGitCommandsInCodeBlock(codeBlock: string, prefix: string): string {
|
||||
return codeBlock
|
||||
.replace(LEADING_GIT_COMMAND_PATTERN, `$1${prefix} git`)
|
||||
.replace(INLINE_GIT_COMMAND_PATTERN, `$1${prefix} git`)
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
if (line.includes(prefix)) {
|
||||
return line
|
||||
}
|
||||
return line
|
||||
.replace(LEADING_GIT_COMMAND_PATTERN, `$1${prefix} git`)
|
||||
.replace(INLINE_GIT_COMMAND_PATTERN, `$1${prefix} git`)
|
||||
})
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
function buildCommitFooterInjection(
|
||||
|
||||
@@ -199,3 +199,236 @@ describe("EXCLUDED_ENV_PATTERNS", () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
describe("secret env var filtering", () => {
|
||||
it("filters out ANTHROPIC_API_KEY", () => {
|
||||
// given
|
||||
process.env.ANTHROPIC_API_KEY = "sk-ant-api03-secret"
|
||||
process.env.PATH = "/usr/bin"
|
||||
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// then
|
||||
expect(cleanEnv.ANTHROPIC_API_KEY).toBeUndefined()
|
||||
expect(cleanEnv.PATH).toBe("/usr/bin")
|
||||
})
|
||||
|
||||
it("filters out AWS_SECRET_ACCESS_KEY", () => {
|
||||
// given
|
||||
process.env.AWS_SECRET_ACCESS_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||||
process.env.AWS_ACCESS_KEY_ID = "AKIAIOSFODNN7EXAMPLE"
|
||||
process.env.HOME = "/home/user"
|
||||
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// then
|
||||
expect(cleanEnv.AWS_SECRET_ACCESS_KEY).toBeUndefined()
|
||||
expect(cleanEnv.AWS_ACCESS_KEY_ID).toBeUndefined()
|
||||
expect(cleanEnv.HOME).toBe("/home/user")
|
||||
})
|
||||
|
||||
it("filters out GITHUB_TOKEN", () => {
|
||||
// given
|
||||
process.env.GITHUB_TOKEN = "ghp_secrettoken123456789"
|
||||
process.env.GITHUB_API_TOKEN = "another_secret_token"
|
||||
process.env.SHELL = "/bin/bash"
|
||||
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// then
|
||||
expect(cleanEnv.GITHUB_TOKEN).toBeUndefined()
|
||||
expect(cleanEnv.GITHUB_API_TOKEN).toBeUndefined()
|
||||
expect(cleanEnv.SHELL).toBe("/bin/bash")
|
||||
})
|
||||
|
||||
it("filters out OPENAI_API_KEY", () => {
|
||||
// given
|
||||
process.env.OPENAI_API_KEY = "sk-secret123456789"
|
||||
process.env.LANG = "en_US.UTF-8"
|
||||
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// then
|
||||
expect(cleanEnv.OPENAI_API_KEY).toBeUndefined()
|
||||
expect(cleanEnv.LANG).toBe("en_US.UTF-8")
|
||||
})
|
||||
|
||||
it("filters out DATABASE_URL with credentials", () => {
|
||||
// given
|
||||
process.env.DATABASE_URL = "postgresql://user:password@localhost:5432/db"
|
||||
process.env.DB_PASSWORD = "supersecretpassword"
|
||||
process.env.TERM = "xterm-256color"
|
||||
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// then
|
||||
expect(cleanEnv.DATABASE_URL).toBeUndefined()
|
||||
expect(cleanEnv.DB_PASSWORD).toBeUndefined()
|
||||
expect(cleanEnv.TERM).toBe("xterm-256color")
|
||||
})
|
||||
})
|
||||
|
||||
describe("suffix-based secret filtering", () => {
|
||||
it("filters variables ending with _KEY", () => {
|
||||
// given
|
||||
process.env.MY_API_KEY = "secret-value"
|
||||
process.env.SOME_KEY = "another-secret"
|
||||
process.env.TMPDIR = "/tmp"
|
||||
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// then
|
||||
expect(cleanEnv.MY_API_KEY).toBeUndefined()
|
||||
expect(cleanEnv.SOME_KEY).toBeUndefined()
|
||||
expect(cleanEnv.TMPDIR).toBe("/tmp")
|
||||
})
|
||||
|
||||
it("filters variables ending with _SECRET", () => {
|
||||
// given
|
||||
process.env.AWS_SECRET = "secret-value"
|
||||
process.env.JWT_SECRET = "jwt-secret-token"
|
||||
process.env.USER = "testuser"
|
||||
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// then
|
||||
expect(cleanEnv.AWS_SECRET).toBeUndefined()
|
||||
expect(cleanEnv.JWT_SECRET).toBeUndefined()
|
||||
expect(cleanEnv.USER).toBe("testuser")
|
||||
})
|
||||
|
||||
it("filters variables ending with _TOKEN", () => {
|
||||
// given
|
||||
process.env.ACCESS_TOKEN = "token-value"
|
||||
process.env.BEARER_TOKEN = "bearer-token"
|
||||
process.env.HOME = "/home/user"
|
||||
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// then
|
||||
expect(cleanEnv.ACCESS_TOKEN).toBeUndefined()
|
||||
expect(cleanEnv.BEARER_TOKEN).toBeUndefined()
|
||||
expect(cleanEnv.HOME).toBe("/home/user")
|
||||
})
|
||||
|
||||
it("filters variables ending with _PASSWORD", () => {
|
||||
// given
|
||||
process.env.DB_PASSWORD = "db-password"
|
||||
process.env.APP_PASSWORD = "app-secret"
|
||||
process.env.NODE_ENV = "production"
|
||||
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// then
|
||||
expect(cleanEnv.DB_PASSWORD).toBeUndefined()
|
||||
expect(cleanEnv.APP_PASSWORD).toBeUndefined()
|
||||
expect(cleanEnv.NODE_ENV).toBe("production")
|
||||
})
|
||||
|
||||
it("filters variables ending with _CREDENTIAL", () => {
|
||||
// given
|
||||
process.env.GCP_CREDENTIAL = "json-credential"
|
||||
process.env.AZURE_CREDENTIAL = "azure-creds"
|
||||
process.env.PWD = "/current/dir"
|
||||
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// then
|
||||
expect(cleanEnv.GCP_CREDENTIAL).toBeUndefined()
|
||||
expect(cleanEnv.AZURE_CREDENTIAL).toBeUndefined()
|
||||
expect(cleanEnv.PWD).toBe("/current/dir")
|
||||
})
|
||||
|
||||
it("filters variables ending with _API_KEY", () => {
|
||||
// given
|
||||
// given
|
||||
process.env.STRIPE_API_KEY = "sk_live_secret"
|
||||
process.env.SENDGRID_API_KEY = "SG.secret"
|
||||
process.env.SHELL = "/bin/zsh"
|
||||
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// then
|
||||
expect(cleanEnv.STRIPE_API_KEY).toBeUndefined()
|
||||
expect(cleanEnv.SENDGRID_API_KEY).toBeUndefined()
|
||||
expect(cleanEnv.SHELL).toBe("/bin/zsh")
|
||||
})
|
||||
})
|
||||
|
||||
describe("safe environment variables preserved", () => {
|
||||
it("preserves PATH", () => {
|
||||
// given
|
||||
process.env.PATH = "/usr/bin:/usr/local/bin"
|
||||
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// then
|
||||
expect(cleanEnv.PATH).toBe("/usr/bin:/usr/local/bin")
|
||||
})
|
||||
|
||||
it("preserves HOME", () => {
|
||||
// given
|
||||
process.env.HOME = "/home/testuser"
|
||||
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// then
|
||||
expect(cleanEnv.HOME).toBe("/home/testuser")
|
||||
})
|
||||
|
||||
it("preserves SHELL", () => {
|
||||
// given
|
||||
process.env.SHELL = "/bin/bash"
|
||||
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// then
|
||||
expect(cleanEnv.SHELL).toBe("/bin/bash")
|
||||
})
|
||||
|
||||
it("preserves LANG", () => {
|
||||
// given
|
||||
process.env.LANG = "en_US.UTF-8"
|
||||
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// then
|
||||
expect(cleanEnv.LANG).toBe("en_US.UTF-8")
|
||||
})
|
||||
|
||||
it("preserves TERM", () => {
|
||||
// given
|
||||
process.env.TERM = "xterm-256color"
|
||||
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// then
|
||||
expect(cleanEnv.TERM).toBe("xterm-256color")
|
||||
})
|
||||
|
||||
it("preserves TMPDIR", () => {
|
||||
// given
|
||||
process.env.TMPDIR = "/tmp"
|
||||
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// then
|
||||
expect(cleanEnv.TMPDIR).toBe("/tmp")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
// Filters npm/pnpm/yarn config env vars that break MCP servers in pnpm projects (#456)
|
||||
// Also filters secret-containing env vars to prevent exposure to malicious stdio MCP servers (#B-02)
|
||||
export const EXCLUDED_ENV_PATTERNS: RegExp[] = [
|
||||
// npm/pnpm/yarn config patterns (original)
|
||||
/^NPM_CONFIG_/i,
|
||||
/^npm_config_/,
|
||||
/^YARN_/,
|
||||
/^PNPM_/,
|
||||
/^NO_UPDATE_NOTIFIER$/,
|
||||
|
||||
// Specific high-risk secret env vars (explicit blocks)
|
||||
/^ANTHROPIC_API_KEY$/i,
|
||||
/^AWS_ACCESS_KEY_ID$/i,
|
||||
/^AWS_SECRET_ACCESS_KEY$/i,
|
||||
/^GITHUB_TOKEN$/i,
|
||||
/^DATABASE_URL$/i,
|
||||
/^OPENAI_API_KEY$/i,
|
||||
|
||||
// Suffix-based patterns for common secret naming conventions
|
||||
/_KEY$/i,
|
||||
/_SECRET$/i,
|
||||
/_TOKEN$/i,
|
||||
/_PASSWORD$/i,
|
||||
/_CREDENTIAL$/i,
|
||||
/_API_KEY$/i,
|
||||
]
|
||||
|
||||
export function createCleanMcpEnvironment(
|
||||
|
||||
@@ -279,6 +279,116 @@ describe("TaskToastManager", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("model name display in task line", () => {
|
||||
test("should show model name before category when modelInfo exists", () => {
|
||||
// given - a task with category and modelInfo
|
||||
const task = {
|
||||
id: "task_model_display",
|
||||
description: "Build UI component",
|
||||
agent: "sisyphus-junior",
|
||||
isBackground: true,
|
||||
category: "deep",
|
||||
modelInfo: { model: "openai/gpt-5.3-codex", type: "category-default" as const },
|
||||
}
|
||||
|
||||
// when - addTask is called
|
||||
toastManager.addTask(task)
|
||||
|
||||
// then - toast should show model name before category like "gpt-5.3-codex: deep"
|
||||
const call = mockClient.tui.showToast.mock.calls[0][0]
|
||||
expect(call.body.message).toContain("gpt-5.3-codex: deep")
|
||||
expect(call.body.message).not.toContain("sisyphus-junior/deep")
|
||||
})
|
||||
|
||||
test("should strip provider prefix from model name", () => {
|
||||
// given - a task with provider-prefixed model
|
||||
const task = {
|
||||
id: "task_strip_provider",
|
||||
description: "Fix styles",
|
||||
agent: "sisyphus-junior",
|
||||
isBackground: false,
|
||||
category: "visual-engineering",
|
||||
modelInfo: { model: "google/gemini-3.1-pro", type: "category-default" as const },
|
||||
}
|
||||
|
||||
// when - addTask is called
|
||||
toastManager.addTask(task)
|
||||
|
||||
// then - should show model ID without provider prefix
|
||||
const call = mockClient.tui.showToast.mock.calls[0][0]
|
||||
expect(call.body.message).toContain("gemini-3.1-pro: visual-engineering")
|
||||
})
|
||||
|
||||
test("should fall back to agent/category format when no modelInfo", () => {
|
||||
// given - a task without modelInfo
|
||||
const task = {
|
||||
id: "task_no_model",
|
||||
description: "Quick fix",
|
||||
agent: "sisyphus-junior",
|
||||
isBackground: true,
|
||||
category: "quick",
|
||||
}
|
||||
|
||||
// when - addTask is called
|
||||
toastManager.addTask(task)
|
||||
|
||||
// then - should use old format with agent name
|
||||
const call = mockClient.tui.showToast.mock.calls[0][0]
|
||||
expect(call.body.message).toContain("sisyphus-junior/quick")
|
||||
})
|
||||
|
||||
test("should show model name without category when category is absent", () => {
|
||||
// given - a task with modelInfo but no category
|
||||
const task = {
|
||||
id: "task_model_no_cat",
|
||||
description: "Explore codebase",
|
||||
agent: "explore",
|
||||
isBackground: true,
|
||||
modelInfo: { model: "anthropic/claude-sonnet-4-6", type: "category-default" as const },
|
||||
}
|
||||
|
||||
// when - addTask is called
|
||||
toastManager.addTask(task)
|
||||
|
||||
// then - should show just the model name in parens
|
||||
const call = mockClient.tui.showToast.mock.calls[0][0]
|
||||
expect(call.body.message).toContain("(claude-sonnet-4-6)")
|
||||
})
|
||||
|
||||
test("should show model name in queued tasks too", () => {
|
||||
// given - a concurrency manager that limits to 1
|
||||
const limitedConcurrency = {
|
||||
getConcurrencyLimit: mock(() => 1),
|
||||
} as unknown as ConcurrencyManager
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const limitedManager = new TaskToastManager(mockClient as any, limitedConcurrency)
|
||||
|
||||
limitedManager.addTask({
|
||||
id: "task_running",
|
||||
description: "Running task",
|
||||
agent: "sisyphus-junior",
|
||||
isBackground: true,
|
||||
category: "deep",
|
||||
modelInfo: { model: "openai/gpt-5.3-codex", type: "category-default" as const },
|
||||
})
|
||||
limitedManager.addTask({
|
||||
id: "task_queued",
|
||||
description: "Queued task",
|
||||
agent: "sisyphus-junior",
|
||||
isBackground: true,
|
||||
category: "quick",
|
||||
status: "queued",
|
||||
modelInfo: { model: "anthropic/claude-haiku-4-5", type: "category-default" as const },
|
||||
})
|
||||
|
||||
// when - the queued task toast fires
|
||||
const lastCall = mockClient.tui.showToast.mock.calls[1][0]
|
||||
|
||||
// then - queued task should also show model name
|
||||
expect(lastCall.body.message).toContain("claude-haiku-4-5: quick")
|
||||
})
|
||||
})
|
||||
|
||||
describe("updateTaskModelBySession", () => {
|
||||
test("updates task model info and shows fallback toast", () => {
|
||||
// given - task without model info
|
||||
|
||||
@@ -127,6 +127,13 @@ export class TaskToastManager {
|
||||
const queued = this.getQueuedTasks()
|
||||
const concurrencyInfo = this.getConcurrencyInfo()
|
||||
|
||||
const formatTaskIdentifier = (task: TrackedTask): string => {
|
||||
const modelName = task.modelInfo?.model?.split("/").pop()
|
||||
if (modelName && task.category) return `${modelName}: ${task.category}`
|
||||
if (modelName) return modelName
|
||||
if (task.category) return `${task.agent}/${task.category}`
|
||||
return task.agent
|
||||
}
|
||||
const lines: string[] = []
|
||||
|
||||
const isFallback = newTask.modelInfo && (
|
||||
@@ -151,9 +158,9 @@ export class TaskToastManager {
|
||||
const duration = this.formatDuration(task.startedAt)
|
||||
const bgIcon = task.isBackground ? "[BG]" : "[RUN]"
|
||||
const isNew = task.id === newTask.id ? " ← NEW" : ""
|
||||
const categoryInfo = task.category ? `/${task.category}` : ""
|
||||
const taskId = formatTaskIdentifier(task)
|
||||
const skillsInfo = task.skills?.length ? ` [${task.skills.join(", ")}]` : ""
|
||||
lines.push(`${bgIcon} ${task.description} (${task.agent}${categoryInfo})${skillsInfo} - ${duration}${isNew}`)
|
||||
lines.push(`${bgIcon} ${task.description} (${taskId})${skillsInfo} - ${duration}${isNew}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,10 +169,10 @@ export class TaskToastManager {
|
||||
lines.push(`Queued (${queued.length}):`)
|
||||
for (const task of queued) {
|
||||
const bgIcon = task.isBackground ? "[Q]" : "[W]"
|
||||
const categoryInfo = task.category ? `/${task.category}` : ""
|
||||
const taskId = formatTaskIdentifier(task)
|
||||
const skillsInfo = task.skills?.length ? ` [${task.skills.join(", ")}]` : ""
|
||||
const isNew = task.id === newTask.id ? " ← NEW" : ""
|
||||
lines.push(`${bgIcon} ${task.description} (${task.agent}${categoryInfo})${skillsInfo} - Queued${isNew}`)
|
||||
lines.push(`${bgIcon} ${task.description} (${taskId})${skillsInfo} - Queued${isNew}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { existsSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { runBunInstallWithDetails } from "../../../cli/config-manager"
|
||||
import { log } from "../../../shared/logger"
|
||||
import { getOpenCodeCacheDir, getOpenCodeConfigPaths } from "../../../shared"
|
||||
import { invalidatePackage } from "../cache"
|
||||
import { PACKAGE_NAME } from "../constants"
|
||||
import { extractChannel } from "../version-channel"
|
||||
@@ -11,9 +14,36 @@ function getPinnedVersionToastMessage(latestVersion: string): string {
|
||||
return `Update available: ${latestVersion} (version pinned, update manually)`
|
||||
}
|
||||
|
||||
async function runBunInstallSafe(): Promise<boolean> {
|
||||
/**
|
||||
* Resolves the active install workspace.
|
||||
* Same logic as doctor check: prefer config-dir if installed, fall back to cache-dir.
|
||||
*/
|
||||
function resolveActiveInstallWorkspace(): string {
|
||||
const configPaths = getOpenCodeConfigPaths({ binary: "opencode" })
|
||||
const cacheDir = getOpenCodeCacheDir()
|
||||
|
||||
const configInstallPath = join(configPaths.configDir, "node_modules", PACKAGE_NAME, "package.json")
|
||||
const cacheInstallPath = join(cacheDir, "node_modules", PACKAGE_NAME, "package.json")
|
||||
|
||||
// Prefer config-dir if installed there, otherwise fall back to cache-dir
|
||||
if (existsSync(configInstallPath)) {
|
||||
log(`[auto-update-checker] Active workspace: config-dir (${configPaths.configDir})`)
|
||||
return configPaths.configDir
|
||||
}
|
||||
|
||||
if (existsSync(cacheInstallPath)) {
|
||||
log(`[auto-update-checker] Active workspace: cache-dir (${cacheDir})`)
|
||||
return cacheDir
|
||||
}
|
||||
|
||||
// Default to config-dir if neither exists (matches doctor behavior)
|
||||
log(`[auto-update-checker] Active workspace: config-dir (default, no install detected)`)
|
||||
return configPaths.configDir
|
||||
}
|
||||
|
||||
async function runBunInstallSafe(workspaceDir: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await runBunInstallWithDetails({ outputMode: "pipe" })
|
||||
const result = await runBunInstallWithDetails({ outputMode: "pipe", workspaceDir })
|
||||
if (!result.success && result.error) {
|
||||
log("[auto-update-checker] bun install error:", result.error)
|
||||
}
|
||||
@@ -82,7 +112,8 @@ export async function runBackgroundUpdateCheck(
|
||||
|
||||
invalidatePackage(PACKAGE_NAME)
|
||||
|
||||
const installSuccess = await runBunInstallSafe()
|
||||
const activeWorkspace = resolveActiveInstallWorkspace()
|
||||
const installSuccess = await runBunInstallSafe(activeWorkspace)
|
||||
|
||||
if (installSuccess) {
|
||||
await showAutoUpdatedToast(ctx, currentVersion, latestVersion)
|
||||
|
||||
223
src/hooks/auto-update-checker/hook/workspace-resolution.test.ts
Normal file
223
src/hooks/auto-update-checker/hook/workspace-resolution.test.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
|
||||
type PluginEntry = {
|
||||
entry: string
|
||||
isPinned: boolean
|
||||
pinnedVersion: string | null
|
||||
configPath: string
|
||||
}
|
||||
|
||||
type ToastMessageGetter = (isUpdate: boolean, version?: string) => string
|
||||
|
||||
function createPluginEntry(overrides?: Partial<PluginEntry>): PluginEntry {
|
||||
return {
|
||||
entry: "oh-my-opencode@3.4.0",
|
||||
isPinned: false,
|
||||
pinnedVersion: null,
|
||||
configPath: "/test/opencode.json",
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const TEST_DIR = join(import.meta.dir, "__test-workspace-resolution__")
|
||||
const TEST_CACHE_DIR = join(TEST_DIR, "cache")
|
||||
const TEST_CONFIG_DIR = join(TEST_DIR, "config")
|
||||
|
||||
const mockFindPluginEntry = mock((_directory: string): PluginEntry | null => createPluginEntry())
|
||||
const mockGetCachedVersion = mock((): string | null => "3.4.0")
|
||||
const mockGetLatestVersion = mock(async (): Promise<string | null> => "3.5.0")
|
||||
const mockExtractChannel = mock(() => "latest")
|
||||
const mockInvalidatePackage = mock(() => {})
|
||||
const mockShowUpdateAvailableToast = mock(
|
||||
async (_ctx: PluginInput, _latestVersion: string, _getToastMessage: ToastMessageGetter): Promise<void> => {}
|
||||
)
|
||||
const mockShowAutoUpdatedToast = mock(
|
||||
async (_ctx: PluginInput, _fromVersion: string, _toVersion: string): Promise<void> => {}
|
||||
)
|
||||
const mockSyncCachePackageJsonToIntent = mock(() => ({ synced: true, error: null }))
|
||||
|
||||
const mockRunBunInstallWithDetails = mock(
|
||||
async (opts?: { outputMode?: string; workspaceDir?: string }) => {
|
||||
return { success: true }
|
||||
}
|
||||
)
|
||||
|
||||
mock.module("../checker", () => ({
|
||||
findPluginEntry: mockFindPluginEntry,
|
||||
getCachedVersion: mockGetCachedVersion,
|
||||
getLatestVersion: mockGetLatestVersion,
|
||||
revertPinnedVersion: mock(() => false),
|
||||
syncCachePackageJsonToIntent: mockSyncCachePackageJsonToIntent,
|
||||
}))
|
||||
mock.module("../version-channel", () => ({ extractChannel: mockExtractChannel }))
|
||||
mock.module("../cache", () => ({ invalidatePackage: mockInvalidatePackage }))
|
||||
mock.module("../../../cli/config-manager", () => ({
|
||||
runBunInstallWithDetails: mockRunBunInstallWithDetails,
|
||||
}))
|
||||
mock.module("./update-toasts", () => ({
|
||||
showUpdateAvailableToast: mockShowUpdateAvailableToast,
|
||||
showAutoUpdatedToast: mockShowAutoUpdatedToast,
|
||||
}))
|
||||
mock.module("../../../shared/logger", () => ({ log: () => {} }))
|
||||
mock.module("../../../shared", () => ({
|
||||
getOpenCodeCacheDir: () => TEST_CACHE_DIR,
|
||||
getOpenCodeConfigPaths: () => ({
|
||||
configDir: TEST_CONFIG_DIR,
|
||||
configJson: join(TEST_CONFIG_DIR, "opencode.json"),
|
||||
configJsonc: join(TEST_CONFIG_DIR, "opencode.jsonc"),
|
||||
packageJson: join(TEST_CONFIG_DIR, "package.json"),
|
||||
omoConfig: join(TEST_CONFIG_DIR, "oh-my-opencode.json"),
|
||||
}),
|
||||
getOpenCodeConfigDir: () => TEST_CONFIG_DIR,
|
||||
}))
|
||||
|
||||
// Mock constants BEFORE importing the module
|
||||
const ORIGINAL_PACKAGE_NAME = "oh-my-opencode"
|
||||
mock.module("../constants", () => ({
|
||||
PACKAGE_NAME: ORIGINAL_PACKAGE_NAME,
|
||||
CACHE_DIR: TEST_CACHE_DIR,
|
||||
USER_CONFIG_DIR: TEST_CONFIG_DIR,
|
||||
}))
|
||||
|
||||
// Need to mock getOpenCodeCacheDir and getOpenCodeConfigPaths before importing the module
|
||||
mock.module("../../../shared/data-path", () => ({
|
||||
getDataDir: () => join(TEST_DIR, "data"),
|
||||
getOpenCodeStorageDir: () => join(TEST_DIR, "data", "opencode", "storage"),
|
||||
getCacheDir: () => TEST_DIR,
|
||||
getOmoOpenCodeCacheDir: () => join(TEST_DIR, "oh-my-opencode"),
|
||||
getOpenCodeCacheDir: () => TEST_CACHE_DIR,
|
||||
}))
|
||||
mock.module("../../../shared/opencode-config-dir", () => ({
|
||||
getOpenCodeConfigDir: () => TEST_CONFIG_DIR,
|
||||
getOpenCodeConfigPaths: () => ({
|
||||
configDir: TEST_CONFIG_DIR,
|
||||
configJson: join(TEST_CONFIG_DIR, "opencode.json"),
|
||||
configJsonc: join(TEST_CONFIG_DIR, "opencode.jsonc"),
|
||||
packageJson: join(TEST_CONFIG_DIR, "package.json"),
|
||||
omoConfig: join(TEST_CONFIG_DIR, "oh-my-opencode.json"),
|
||||
}),
|
||||
}))
|
||||
|
||||
const modulePath = "./background-update-check?test"
|
||||
const { runBackgroundUpdateCheck } = await import(modulePath)
|
||||
|
||||
describe("workspace resolution", () => {
|
||||
const mockCtx = { directory: "/test" } as PluginInput
|
||||
const getToastMessage: ToastMessageGetter = (isUpdate, version) =>
|
||||
isUpdate ? `Update to ${version}` : "Up to date"
|
||||
|
||||
beforeEach(() => {
|
||||
// Setup test directories
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
}
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
|
||||
mockFindPluginEntry.mockReset()
|
||||
mockGetCachedVersion.mockReset()
|
||||
mockGetLatestVersion.mockReset()
|
||||
mockExtractChannel.mockReset()
|
||||
mockInvalidatePackage.mockReset()
|
||||
mockRunBunInstallWithDetails.mockReset()
|
||||
mockShowUpdateAvailableToast.mockReset()
|
||||
mockShowAutoUpdatedToast.mockReset()
|
||||
|
||||
mockFindPluginEntry.mockReturnValue(createPluginEntry())
|
||||
mockGetCachedVersion.mockReturnValue("3.4.0")
|
||||
mockGetLatestVersion.mockResolvedValue("3.5.0")
|
||||
mockExtractChannel.mockReturnValue("latest")
|
||||
// Note: Don't use mockResolvedValue here - it overrides the function that captures args
|
||||
mockSyncCachePackageJsonToIntent.mockReturnValue({ synced: true, error: null })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe("#given config-dir install exists but cache-dir does not", () => {
|
||||
it("installs to config-dir, not cache-dir", async () => {
|
||||
//#given - config-dir has installation, cache-dir does not
|
||||
mkdirSync(join(TEST_CONFIG_DIR, "node_modules", "oh-my-opencode"), { recursive: true })
|
||||
writeFileSync(
|
||||
join(TEST_CONFIG_DIR, "package.json"),
|
||||
JSON.stringify({ dependencies: { "oh-my-opencode": "3.4.0" } }, null, 2)
|
||||
)
|
||||
writeFileSync(
|
||||
join(TEST_CONFIG_DIR, "node_modules", "oh-my-opencode", "package.json"),
|
||||
JSON.stringify({ name: "oh-my-opencode", version: "3.4.0" }, null, 2)
|
||||
)
|
||||
|
||||
// cache-dir should NOT exist
|
||||
expect(existsSync(TEST_CACHE_DIR)).toBe(false)
|
||||
|
||||
//#when
|
||||
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
|
||||
|
||||
//#then - install should be called with config-dir
|
||||
const mockCalls = mockRunBunInstallWithDetails.mock.calls
|
||||
expect(mockCalls[0][0]?.workspaceDir).toBe(TEST_CONFIG_DIR)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given both config-dir and cache-dir exist", () => {
|
||||
it("prefers config-dir over cache-dir", async () => {
|
||||
//#given - both directories have installations
|
||||
mkdirSync(join(TEST_CONFIG_DIR, "node_modules", "oh-my-opencode"), { recursive: true })
|
||||
writeFileSync(
|
||||
join(TEST_CONFIG_DIR, "package.json"),
|
||||
JSON.stringify({ dependencies: { "oh-my-opencode": "3.4.0" } }, null, 2)
|
||||
)
|
||||
writeFileSync(
|
||||
join(TEST_CONFIG_DIR, "node_modules", "oh-my-opencode", "package.json"),
|
||||
JSON.stringify({ name: "oh-my-opencode", version: "3.4.0" }, null, 2)
|
||||
)
|
||||
|
||||
mkdirSync(join(TEST_CACHE_DIR, "node_modules", "oh-my-opencode"), { recursive: true })
|
||||
writeFileSync(
|
||||
join(TEST_CACHE_DIR, "package.json"),
|
||||
JSON.stringify({ dependencies: { "oh-my-opencode": "3.4.0" } }, null, 2)
|
||||
)
|
||||
writeFileSync(
|
||||
join(TEST_CACHE_DIR, "node_modules", "oh-my-opencode", "package.json"),
|
||||
JSON.stringify({ name: "oh-my-opencode", version: "3.4.0" }, null, 2)
|
||||
)
|
||||
|
||||
//#when
|
||||
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
|
||||
|
||||
//#then - install should prefer config-dir
|
||||
const mockCalls2 = mockRunBunInstallWithDetails.mock.calls
|
||||
expect(mockCalls2[0][0]?.workspaceDir).toBe(TEST_CONFIG_DIR)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given only cache-dir install exists", () => {
|
||||
it("falls back to cache-dir", async () => {
|
||||
//#given - only cache-dir has installation
|
||||
mkdirSync(join(TEST_CACHE_DIR, "node_modules", "oh-my-opencode"), { recursive: true })
|
||||
writeFileSync(
|
||||
join(TEST_CACHE_DIR, "package.json"),
|
||||
JSON.stringify({ dependencies: { "oh-my-opencode": "3.4.0" } }, null, 2)
|
||||
)
|
||||
writeFileSync(
|
||||
join(TEST_CACHE_DIR, "node_modules", "oh-my-opencode", "package.json"),
|
||||
JSON.stringify({ name: "oh-my-opencode", version: "3.4.0" }, null, 2)
|
||||
)
|
||||
|
||||
// config-dir should NOT exist
|
||||
expect(existsSync(TEST_CONFIG_DIR)).toBe(false)
|
||||
|
||||
//#when
|
||||
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
|
||||
|
||||
//#then - install should fall back to cache-dir
|
||||
const mockCalls3 = mockRunBunInstallWithDetails.mock.calls
|
||||
expect(mockCalls3[0][0]?.workspaceDir).toBe(TEST_CACHE_DIR)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,70 +0,0 @@
|
||||
import { wakeOpenClaw } from "../../openclaw/client";
|
||||
import type { OpenClawConfig, OpenClawContext } from "../../openclaw/types";
|
||||
import { getMainSessionID } from "../../features/claude-code-session-state";
|
||||
import type { PluginContext } from "../../plugin/types";
|
||||
|
||||
export function createOpenClawSenderHook(
|
||||
ctx: PluginContext,
|
||||
config: OpenClawConfig
|
||||
) {
|
||||
return {
|
||||
event: async (input: {
|
||||
event: { type: string; properties?: Record<string, unknown> };
|
||||
}) => {
|
||||
const { type, properties } = input.event;
|
||||
const info = properties?.info as Record<string, unknown> | undefined;
|
||||
const context: OpenClawContext = {
|
||||
sessionId:
|
||||
(properties?.sessionID as string) ||
|
||||
(info?.id as string) ||
|
||||
getMainSessionID(),
|
||||
projectPath: ctx.directory,
|
||||
};
|
||||
|
||||
if (type === "session.created") {
|
||||
await wakeOpenClaw("session-start", context, config);
|
||||
} else if (type === "session.idle") {
|
||||
await wakeOpenClaw("session-idle", context, config);
|
||||
} else if (type === "session.deleted") {
|
||||
await wakeOpenClaw("session-end", context, config);
|
||||
}
|
||||
},
|
||||
|
||||
"tool.execute.before": async (
|
||||
input: { tool: string; sessionID: string },
|
||||
output: { args: Record<string, unknown> }
|
||||
) => {
|
||||
const toolName = input.tool.toLowerCase();
|
||||
const context: OpenClawContext = {
|
||||
sessionId: input.sessionID,
|
||||
projectPath: ctx.directory,
|
||||
};
|
||||
|
||||
if (
|
||||
toolName === "ask_user_question" ||
|
||||
toolName === "askuserquestion" ||
|
||||
toolName === "question"
|
||||
) {
|
||||
const question =
|
||||
typeof output.args.question === "string"
|
||||
? output.args.question
|
||||
: undefined;
|
||||
await wakeOpenClaw(
|
||||
"ask-user-question",
|
||||
{
|
||||
...context,
|
||||
question,
|
||||
},
|
||||
config
|
||||
);
|
||||
} else if (toolName === "skill") {
|
||||
const rawName =
|
||||
typeof output.args.name === "string" ? output.args.name : undefined;
|
||||
const command = rawName?.replace(/^\//, "").toLowerCase();
|
||||
if (command === "stop-continuation") {
|
||||
await wakeOpenClaw("stop", context, config);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,96 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { log } from "../../shared/logger"
|
||||
import { HOOK_NAME } from "./constants"
|
||||
import { ULTRAWORK_VERIFICATION_PROMISE } from "./constants"
|
||||
import type { RalphLoopState } from "./types"
|
||||
import { handleFailedVerification } from "./verification-failure-handler"
|
||||
import { withTimeout } from "./with-timeout"
|
||||
|
||||
type OpenCodeSessionMessage = {
|
||||
info?: { role?: string }
|
||||
parts?: Array<{ type?: string; text?: string }>
|
||||
}
|
||||
|
||||
const ORACLE_AGENT_PATTERN = /Agent:\s*oracle/i
|
||||
const TASK_METADATA_SESSION_PATTERN = /<task_metadata>[\s\S]*?session_id:\s*([^\s<]+)[\s\S]*?<\/task_metadata>/i
|
||||
const VERIFIED_PROMISE_PATTERN = new RegExp(
|
||||
`<promise>\\s*${ULTRAWORK_VERIFICATION_PROMISE}\\s*<\\/promise>`,
|
||||
"i",
|
||||
)
|
||||
|
||||
function collectAssistantText(message: OpenCodeSessionMessage): string {
|
||||
if (!Array.isArray(message.parts)) {
|
||||
return ""
|
||||
}
|
||||
|
||||
let text = ""
|
||||
for (const part of message.parts) {
|
||||
if (part.type !== "text") {
|
||||
continue
|
||||
}
|
||||
text += `${text ? "\n" : ""}${part.text ?? ""}`
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
async function detectOracleVerificationFromParentSession(
|
||||
ctx: PluginInput,
|
||||
parentSessionID: string,
|
||||
directory: string,
|
||||
apiTimeoutMs: number,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const response = await withTimeout(
|
||||
ctx.client.session.messages({
|
||||
path: { id: parentSessionID },
|
||||
query: { directory },
|
||||
}),
|
||||
apiTimeoutMs,
|
||||
)
|
||||
|
||||
const messagesResponse: unknown = response
|
||||
const responseData =
|
||||
typeof messagesResponse === "object" && messagesResponse !== null && "data" in messagesResponse
|
||||
? (messagesResponse as { data?: unknown }).data
|
||||
: undefined
|
||||
const messageArray: unknown[] = Array.isArray(messagesResponse)
|
||||
? messagesResponse
|
||||
: Array.isArray(responseData)
|
||||
? responseData
|
||||
: []
|
||||
|
||||
for (let index = messageArray.length - 1; index >= 0; index -= 1) {
|
||||
const message = messageArray[index] as OpenCodeSessionMessage
|
||||
if (message.info?.role !== "assistant") {
|
||||
continue
|
||||
}
|
||||
|
||||
const assistantText = collectAssistantText(message)
|
||||
if (!VERIFIED_PROMISE_PATTERN.test(assistantText) || !ORACLE_AGENT_PATTERN.test(assistantText)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const sessionMatch = assistantText.match(TASK_METADATA_SESSION_PATTERN)
|
||||
const detectedOracleSessionID = sessionMatch?.[1]?.trim()
|
||||
if (detectedOracleSessionID) {
|
||||
return detectedOracleSessionID
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
} catch (error) {
|
||||
log(`[${HOOK_NAME}] Failed to scan parent session for oracle verification evidence`, {
|
||||
parentSessionID,
|
||||
error: String(error),
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
type LoopStateController = {
|
||||
restartAfterFailedVerification: (sessionID: string, messageCountAtStart?: number) => RalphLoopState | null
|
||||
setVerificationSessionID: (sessionID: string, verificationSessionID: string) => RalphLoopState | null
|
||||
}
|
||||
|
||||
export async function handlePendingVerification(
|
||||
@@ -33,6 +118,29 @@ export async function handlePendingVerification(
|
||||
} = input
|
||||
|
||||
if (matchesParentSession || (verificationSessionID && matchesVerificationSession)) {
|
||||
if (!verificationSessionID && state.session_id) {
|
||||
const recoveredVerificationSessionID = await detectOracleVerificationFromParentSession(
|
||||
ctx,
|
||||
state.session_id,
|
||||
directory,
|
||||
apiTimeoutMs,
|
||||
)
|
||||
|
||||
if (recoveredVerificationSessionID) {
|
||||
const updatedState = loopState.setVerificationSessionID(
|
||||
state.session_id,
|
||||
recoveredVerificationSessionID,
|
||||
)
|
||||
if (updatedState) {
|
||||
log(`[${HOOK_NAME}] Recovered missing verification session from parent evidence`, {
|
||||
parentSessionID: state.session_id,
|
||||
recoveredVerificationSessionID,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const restarted = await handleFailedVerification(ctx, {
|
||||
state,
|
||||
loopState,
|
||||
|
||||
@@ -136,6 +136,13 @@ export function createRalphLoopEventHandler(
|
||||
}
|
||||
|
||||
if (state.verification_pending) {
|
||||
if (!verificationSessionID && matchesParentSession) {
|
||||
log(`[${HOOK_NAME}] Verification pending without tracked oracle session, running recovery check`, {
|
||||
sessionID,
|
||||
iteration: state.iteration,
|
||||
})
|
||||
}
|
||||
|
||||
await handlePendingVerification(ctx, {
|
||||
sessionID,
|
||||
state,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { classifyErrorType, extractAutoRetrySignal, isRetryableError } from "./error-classifier"
|
||||
import { classifyErrorType, extractAutoRetrySignal, extractStatusCode, isRetryableError } from "./error-classifier"
|
||||
|
||||
describe("runtime-fallback error classifier", () => {
|
||||
test("detects cooling-down auto-retry status signals", () => {
|
||||
@@ -97,3 +97,72 @@ describe("runtime-fallback error classifier", () => {
|
||||
expect(signal).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("extractStatusCode", () => {
|
||||
test("extracts numeric statusCode from top-level", () => {
|
||||
expect(extractStatusCode({ statusCode: 429 })).toBe(429)
|
||||
})
|
||||
|
||||
test("extracts numeric status from top-level", () => {
|
||||
expect(extractStatusCode({ status: 503 })).toBe(503)
|
||||
})
|
||||
|
||||
test("extracts statusCode from nested data", () => {
|
||||
expect(extractStatusCode({ data: { statusCode: 500 } })).toBe(500)
|
||||
})
|
||||
|
||||
test("extracts statusCode from nested error", () => {
|
||||
expect(extractStatusCode({ error: { statusCode: 502 } })).toBe(502)
|
||||
})
|
||||
|
||||
test("extracts statusCode from nested cause", () => {
|
||||
expect(extractStatusCode({ cause: { statusCode: 504 } })).toBe(504)
|
||||
})
|
||||
|
||||
test("skips non-numeric status and finds deeper numeric statusCode", () => {
|
||||
//#given — status is a string, but error.statusCode is numeric
|
||||
const error = {
|
||||
status: "error",
|
||||
error: { statusCode: 429 },
|
||||
}
|
||||
|
||||
//#when
|
||||
const code = extractStatusCode(error)
|
||||
|
||||
//#then
|
||||
expect(code).toBe(429)
|
||||
})
|
||||
|
||||
test("skips non-numeric statusCode string and finds numeric in cause", () => {
|
||||
const error = {
|
||||
statusCode: "UNKNOWN",
|
||||
status: "failed",
|
||||
cause: { statusCode: 503 },
|
||||
}
|
||||
|
||||
expect(extractStatusCode(error)).toBe(503)
|
||||
})
|
||||
|
||||
test("returns undefined when no numeric status exists", () => {
|
||||
expect(extractStatusCode({ status: "error", message: "something broke" })).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns undefined for null/undefined error", () => {
|
||||
expect(extractStatusCode(null)).toBeUndefined()
|
||||
expect(extractStatusCode(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("falls back to regex match in error message", () => {
|
||||
const error = { message: "Request failed with status code 429" }
|
||||
expect(extractStatusCode(error, [429, 503])).toBe(429)
|
||||
})
|
||||
|
||||
test("prefers top-level numeric over nested numeric", () => {
|
||||
const error = {
|
||||
statusCode: 400,
|
||||
error: { statusCode: 429 },
|
||||
cause: { statusCode: 503 },
|
||||
}
|
||||
expect(extractStatusCode(error)).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,8 +33,15 @@ export function extractStatusCode(error: unknown, retryOnErrors?: number[]): num
|
||||
|
||||
const errorObj = error as Record<string, unknown>
|
||||
|
||||
const statusCode = errorObj.statusCode ?? errorObj.status ?? (errorObj.data as Record<string, unknown>)?.statusCode
|
||||
if (typeof statusCode === "number") {
|
||||
const statusCode = [
|
||||
errorObj.statusCode,
|
||||
errorObj.status,
|
||||
(errorObj.data as Record<string, unknown>)?.statusCode,
|
||||
(errorObj.error as Record<string, unknown>)?.statusCode,
|
||||
(errorObj.cause as Record<string, unknown>)?.statusCode,
|
||||
].find((code): code is number => typeof code === "number")
|
||||
|
||||
if (statusCode !== undefined) {
|
||||
return statusCode
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ describe("createSessionStateStore regressions", () => {
|
||||
|
||||
describe("#given external activity happens after a successful continuation", () => {
|
||||
describe("#when todos stay unchanged", () => {
|
||||
test("#then it treats the activity as progress instead of stagnation", () => {
|
||||
test("#then it keeps counting stagnation", () => {
|
||||
const sessionID = "ses-activity-progress"
|
||||
const todos = [
|
||||
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
|
||||
@@ -37,9 +37,9 @@ describe("createSessionStateStore regressions", () => {
|
||||
trackedState.abortDetectedAt = undefined
|
||||
const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2, todos)
|
||||
|
||||
expect(progressUpdate.hasProgressed).toBe(true)
|
||||
expect(progressUpdate.progressSource).toBe("activity")
|
||||
expect(progressUpdate.stagnationCount).toBe(0)
|
||||
expect(progressUpdate.hasProgressed).toBe(false)
|
||||
expect(progressUpdate.progressSource).toBe("none")
|
||||
expect(progressUpdate.stagnationCount).toBe(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -72,7 +72,7 @@ describe("createSessionStateStore regressions", () => {
|
||||
|
||||
describe("#given stagnation already halted a session", () => {
|
||||
describe("#when new activity appears before the next idle check", () => {
|
||||
test("#then it resets the stop condition on the next progress check", () => {
|
||||
test("#then it does not reset the stop condition", () => {
|
||||
const sessionID = "ses-stagnation-recovery"
|
||||
const todos = [
|
||||
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
|
||||
@@ -96,9 +96,9 @@ describe("createSessionStateStore regressions", () => {
|
||||
const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2, todos)
|
||||
|
||||
expect(progressUpdate.previousStagnationCount).toBe(MAX_STAGNATION_COUNT)
|
||||
expect(progressUpdate.hasProgressed).toBe(true)
|
||||
expect(progressUpdate.progressSource).toBe("activity")
|
||||
expect(progressUpdate.stagnationCount).toBe(0)
|
||||
expect(progressUpdate.hasProgressed).toBe(false)
|
||||
expect(progressUpdate.progressSource).toBe("none")
|
||||
expect(progressUpdate.stagnationCount).toBe(MAX_STAGNATION_COUNT)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,8 +16,6 @@ interface TrackedSessionState {
|
||||
lastAccessedAt: number
|
||||
lastCompletedCount?: number
|
||||
lastTodoSnapshot?: string
|
||||
activitySignalCount: number
|
||||
lastObservedActivitySignalCount?: number
|
||||
}
|
||||
|
||||
export interface ContinuationProgressUpdate {
|
||||
@@ -25,7 +23,7 @@ export interface ContinuationProgressUpdate {
|
||||
previousStagnationCount: number
|
||||
stagnationCount: number
|
||||
hasProgressed: boolean
|
||||
progressSource: "none" | "todo" | "activity"
|
||||
progressSource: "none" | "todo"
|
||||
}
|
||||
|
||||
export interface SessionStateStore {
|
||||
@@ -98,17 +96,7 @@ export function createSessionStateStore(): SessionStateStore {
|
||||
const trackedSession: TrackedSessionState = {
|
||||
state: rawState,
|
||||
lastAccessedAt: Date.now(),
|
||||
activitySignalCount: 0,
|
||||
}
|
||||
trackedSession.state = new Proxy(rawState, {
|
||||
set(target, property, value, receiver) {
|
||||
if (property === "abortDetectedAt" && value === undefined) {
|
||||
trackedSession.activitySignalCount += 1
|
||||
}
|
||||
|
||||
return Reflect.set(target, property, value, receiver)
|
||||
},
|
||||
})
|
||||
sessions.set(sessionID, trackedSession)
|
||||
return trackedSession
|
||||
}
|
||||
@@ -137,7 +125,6 @@ export function createSessionStateStore(): SessionStateStore {
|
||||
const previousStagnationCount = state.stagnationCount
|
||||
const currentCompletedCount = todos?.filter((todo) => todo.status === "completed").length
|
||||
const currentTodoSnapshot = todos ? getTodoSnapshot(todos) : undefined
|
||||
const currentActivitySignalCount = trackedSession.activitySignalCount
|
||||
const hasCompletedMoreTodos =
|
||||
currentCompletedCount !== undefined
|
||||
&& trackedSession.lastCompletedCount !== undefined
|
||||
@@ -146,9 +133,6 @@ export function createSessionStateStore(): SessionStateStore {
|
||||
currentTodoSnapshot !== undefined
|
||||
&& trackedSession.lastTodoSnapshot !== undefined
|
||||
&& currentTodoSnapshot !== trackedSession.lastTodoSnapshot
|
||||
const hasObservedExternalActivity =
|
||||
trackedSession.lastObservedActivitySignalCount !== undefined
|
||||
&& currentActivitySignalCount > trackedSession.lastObservedActivitySignalCount
|
||||
const hadSuccessfulInjectionAwaitingProgressCheck = state.awaitingPostInjectionProgressCheck === true
|
||||
|
||||
state.lastIncompleteCount = incompleteCount
|
||||
@@ -158,7 +142,6 @@ export function createSessionStateStore(): SessionStateStore {
|
||||
if (currentTodoSnapshot !== undefined) {
|
||||
trackedSession.lastTodoSnapshot = currentTodoSnapshot
|
||||
}
|
||||
trackedSession.lastObservedActivitySignalCount = currentActivitySignalCount
|
||||
|
||||
if (previousIncompleteCount === undefined) {
|
||||
state.stagnationCount = 0
|
||||
@@ -173,9 +156,7 @@ export function createSessionStateStore(): SessionStateStore {
|
||||
|
||||
const progressSource = incompleteCount < previousIncompleteCount || hasCompletedMoreTodos || hasTodoSnapshotChanged
|
||||
? "todo"
|
||||
: hasObservedExternalActivity
|
||||
? "activity"
|
||||
: "none"
|
||||
: "none"
|
||||
|
||||
if (progressSource !== "none") {
|
||||
state.stagnationCount = 0
|
||||
@@ -223,8 +204,6 @@ export function createSessionStateStore(): SessionStateStore {
|
||||
state.awaitingPostInjectionProgressCheck = false
|
||||
trackedSession.lastCompletedCount = undefined
|
||||
trackedSession.lastTodoSnapshot = undefined
|
||||
trackedSession.activitySignalCount = 0
|
||||
trackedSession.lastObservedActivitySignalCount = undefined
|
||||
}
|
||||
|
||||
function cancelCountdown(sessionID: string): void {
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { describe, expect, it as test } from "bun:test"
|
||||
|
||||
import { MAX_STAGNATION_COUNT } from "./constants"
|
||||
import { handleNonIdleEvent } from "./non-idle-events"
|
||||
import { createSessionStateStore } from "./session-state"
|
||||
import { shouldStopForStagnation } from "./stagnation-detection"
|
||||
|
||||
describe("shouldStopForStagnation", () => {
|
||||
@@ -25,7 +27,7 @@ describe("shouldStopForStagnation", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when activity progress is detected after the halt", () => {
|
||||
describe("#when todo progress is detected after the halt", () => {
|
||||
test("#then it clears the stop condition", () => {
|
||||
const shouldStop = shouldStopForStagnation({
|
||||
sessionID: "ses-recovered",
|
||||
@@ -35,7 +37,7 @@ describe("shouldStopForStagnation", () => {
|
||||
previousStagnationCount: MAX_STAGNATION_COUNT,
|
||||
stagnationCount: 0,
|
||||
hasProgressed: true,
|
||||
progressSource: "activity",
|
||||
progressSource: "todo",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -43,4 +45,60 @@ describe("shouldStopForStagnation", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given only non-idle tool and message events happen between idle checks", () => {
|
||||
describe("#when todo state does not change across three idle cycles", () => {
|
||||
test("#then stagnation count reaches three", () => {
|
||||
// given
|
||||
const sessionStateStore = createSessionStateStore()
|
||||
const sessionID = "ses-non-idle-activity-without-progress"
|
||||
const state = sessionStateStore.getState(sessionID)
|
||||
const todos = [
|
||||
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
|
||||
{ id: "2", content: "Task 2", status: "pending", priority: "medium" },
|
||||
]
|
||||
|
||||
sessionStateStore.trackContinuationProgress(sessionID, 2, todos)
|
||||
|
||||
// when
|
||||
state.awaitingPostInjectionProgressCheck = true
|
||||
const firstCycle = sessionStateStore.trackContinuationProgress(sessionID, 2, todos)
|
||||
|
||||
handleNonIdleEvent({
|
||||
eventType: "tool.execute.before",
|
||||
properties: { sessionID },
|
||||
sessionStateStore,
|
||||
})
|
||||
handleNonIdleEvent({
|
||||
eventType: "message.updated",
|
||||
properties: { info: { sessionID, role: "assistant" } },
|
||||
sessionStateStore,
|
||||
})
|
||||
|
||||
state.awaitingPostInjectionProgressCheck = true
|
||||
const secondCycle = sessionStateStore.trackContinuationProgress(sessionID, 2, todos)
|
||||
|
||||
handleNonIdleEvent({
|
||||
eventType: "tool.execute.after",
|
||||
properties: { sessionID },
|
||||
sessionStateStore,
|
||||
})
|
||||
handleNonIdleEvent({
|
||||
eventType: "message.part.updated",
|
||||
properties: { info: { sessionID, role: "assistant" } },
|
||||
sessionStateStore,
|
||||
})
|
||||
|
||||
state.awaitingPostInjectionProgressCheck = true
|
||||
const thirdCycle = sessionStateStore.trackContinuationProgress(sessionID, 2, todos)
|
||||
|
||||
// then
|
||||
expect(firstCycle.stagnationCount).toBe(1)
|
||||
expect(secondCycle.stagnationCount).toBe(2)
|
||||
expect(thirdCycle.stagnationCount).toBe(3)
|
||||
|
||||
sessionStateStore.shutdown()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { resolveGateway, wakeOpenClaw } from "../client";
|
||||
import { type OpenClawConfig } from "../types";
|
||||
|
||||
describe("OpenClaw Client", () => {
|
||||
describe("resolveGateway", () => {
|
||||
const config: OpenClawConfig = {
|
||||
enabled: true,
|
||||
gateways: {
|
||||
foo: { type: "command", command: "echo foo" },
|
||||
bar: { type: "http", url: "https://example.com" },
|
||||
},
|
||||
hooks: {
|
||||
"session-start": {
|
||||
gateway: "foo",
|
||||
instruction: "start",
|
||||
enabled: true,
|
||||
},
|
||||
"session-end": { gateway: "bar", instruction: "end", enabled: true },
|
||||
stop: { gateway: "foo", instruction: "stop", enabled: false },
|
||||
},
|
||||
};
|
||||
|
||||
it("resolves valid mapping", () => {
|
||||
const result = resolveGateway(config, "session-start");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.gatewayName).toBe("foo");
|
||||
expect(result?.instruction).toBe("start");
|
||||
});
|
||||
|
||||
it("returns null for disabled hook", () => {
|
||||
const result = resolveGateway(config, "stop");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for unmapped event", () => {
|
||||
const result = resolveGateway(config, "ask-user-question");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("wakeOpenClaw env gate", () => {
|
||||
let oldEnv: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
oldEnv = process.env.OMO_OPENCLAW;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (oldEnv === undefined) {
|
||||
delete process.env.OMO_OPENCLAW;
|
||||
} else {
|
||||
process.env.OMO_OPENCLAW = oldEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null when OMO_OPENCLAW is not set", async () => {
|
||||
delete process.env.OMO_OPENCLAW;
|
||||
const config: OpenClawConfig = {
|
||||
enabled: true,
|
||||
gateways: { gw: { type: "command", command: "echo test" } },
|
||||
hooks: {
|
||||
"session-start": { gateway: "gw", instruction: "hi", enabled: true },
|
||||
},
|
||||
};
|
||||
const result = await wakeOpenClaw("session-start", { projectPath: "/tmp" }, config);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when OMO_OPENCLAW is not '1'", async () => {
|
||||
process.env.OMO_OPENCLAW = "0";
|
||||
const config: OpenClawConfig = {
|
||||
enabled: true,
|
||||
gateways: { gw: { type: "command", command: "echo test" } },
|
||||
hooks: {
|
||||
"session-start": { gateway: "gw", instruction: "hi", enabled: true },
|
||||
},
|
||||
};
|
||||
const result = await wakeOpenClaw("session-start", { projectPath: "/tmp" }, config);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("does not use OMX_OPENCLAW (old env var)", async () => {
|
||||
delete process.env.OMO_OPENCLAW;
|
||||
process.env.OMX_OPENCLAW = "1";
|
||||
const config: OpenClawConfig = {
|
||||
enabled: true,
|
||||
gateways: { gw: { type: "command", command: "echo test" } },
|
||||
hooks: {
|
||||
"session-start": { gateway: "gw", instruction: "hi", enabled: true },
|
||||
},
|
||||
};
|
||||
const result = await wakeOpenClaw("session-start", { projectPath: "/tmp" }, config);
|
||||
expect(result).toBeNull();
|
||||
delete process.env.OMX_OPENCLAW;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { OpenClawConfigSchema } from "../../config/schema/openclaw";
|
||||
|
||||
describe("OpenClaw Config Schema", () => {
|
||||
it("validates correct config", () => {
|
||||
const raw = {
|
||||
enabled: true,
|
||||
gateways: {
|
||||
foo: { type: "command", command: "echo foo" },
|
||||
bar: { type: "http", url: "https://example.com" },
|
||||
},
|
||||
hooks: {
|
||||
"session-start": {
|
||||
gateway: "foo",
|
||||
instruction: "start",
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
const parsed = OpenClawConfigSchema.safeParse(raw);
|
||||
if (!parsed.success) console.log(parsed.error);
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("fails on invalid event", () => {
|
||||
const raw = {
|
||||
enabled: true,
|
||||
gateways: {},
|
||||
hooks: {
|
||||
"invalid-event": {
|
||||
gateway: "foo",
|
||||
instruction: "start",
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
const parsed = OpenClawConfigSchema.safeParse(raw);
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import {
|
||||
interpolateInstruction,
|
||||
resolveCommandTimeoutMs,
|
||||
shellEscapeArg,
|
||||
validateGatewayUrl,
|
||||
wakeCommandGateway,
|
||||
} from "../dispatcher";
|
||||
import { type OpenClawCommandGatewayConfig } from "../types";
|
||||
|
||||
describe("OpenClaw Dispatcher", () => {
|
||||
describe("validateGatewayUrl", () => {
|
||||
it("accepts valid https URLs", () => {
|
||||
expect(validateGatewayUrl("https://example.com")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects http URLs (remote)", () => {
|
||||
expect(validateGatewayUrl("http://example.com")).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts http URLs for localhost", () => {
|
||||
expect(validateGatewayUrl("http://localhost:3000")).toBe(true);
|
||||
expect(validateGatewayUrl("http://127.0.0.1:8080")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("interpolateInstruction", () => {
|
||||
it("interpolates variables correctly", () => {
|
||||
const result = interpolateInstruction("Hello {{name}}!", { name: "World" });
|
||||
expect(result).toBe("Hello World!");
|
||||
});
|
||||
|
||||
it("handles missing variables", () => {
|
||||
const result = interpolateInstruction("Hello {{name}}!", {});
|
||||
expect(result).toBe("Hello !");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shellEscapeArg", () => {
|
||||
it("escapes simple string", () => {
|
||||
expect(shellEscapeArg("foo")).toBe("'foo'");
|
||||
});
|
||||
|
||||
it("escapes string with single quotes", () => {
|
||||
expect(shellEscapeArg("it's")).toBe("'it'\\''s'");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCommandTimeoutMs", () => {
|
||||
it("uses default timeout", () => {
|
||||
expect(resolveCommandTimeoutMs(undefined, undefined)).toBe(5000);
|
||||
});
|
||||
|
||||
it("uses provided timeout", () => {
|
||||
expect(resolveCommandTimeoutMs(1000, undefined)).toBe(1000);
|
||||
});
|
||||
|
||||
it("clamps timeout", () => {
|
||||
expect(resolveCommandTimeoutMs(10, undefined)).toBe(100);
|
||||
expect(resolveCommandTimeoutMs(1000000, undefined)).toBe(300000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wakeCommandGateway", () => {
|
||||
it("rejects if disabled via env", async () => {
|
||||
const oldEnv = process.env.OMO_OPENCLAW_COMMAND;
|
||||
process.env.OMO_OPENCLAW_COMMAND = "0";
|
||||
const config: OpenClawCommandGatewayConfig = {
|
||||
type: "command",
|
||||
command: "echo hi",
|
||||
};
|
||||
const result = await wakeCommandGateway("test", config, {});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("disabled");
|
||||
process.env.OMO_OPENCLAW_COMMAND = oldEnv;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,256 +0,0 @@
|
||||
/**
|
||||
* OpenClaw Integration - Client
|
||||
*
|
||||
* Wakes OpenClaw gateways on hook events. Non-blocking, fire-and-forget.
|
||||
*
|
||||
* Usage:
|
||||
* wakeOpenClaw("session-start", { sessionId, projectPath: directory }, config);
|
||||
*
|
||||
* Activation requires OMO_OPENCLAW=1 env var and config in pluginConfig.openclaw.
|
||||
*/
|
||||
|
||||
import {
|
||||
type OpenClawConfig,
|
||||
type OpenClawContext,
|
||||
type OpenClawHookEvent,
|
||||
type OpenClawResult,
|
||||
type OpenClawGatewayConfig,
|
||||
type OpenClawHttpGatewayConfig,
|
||||
type OpenClawCommandGatewayConfig,
|
||||
type OpenClawPayload,
|
||||
} from "./types";
|
||||
import {
|
||||
interpolateInstruction,
|
||||
isCommandGateway,
|
||||
wakeCommandGateway,
|
||||
wakeGateway,
|
||||
} from "./dispatcher";
|
||||
import { execSync } from "child_process";
|
||||
import { basename } from "path";
|
||||
|
||||
/** Whether debug logging is enabled */
|
||||
const DEBUG = process.env.OMO_OPENCLAW_DEBUG === "1";
|
||||
|
||||
// Helper for tmux session
|
||||
function getCurrentTmuxSession(): string | undefined {
|
||||
if (!process.env.TMUX) return undefined;
|
||||
try {
|
||||
// tmux display-message -p '#S'
|
||||
const session = execSync("tmux display-message -p '#S'", {
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
return session || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper for tmux capture
|
||||
function captureTmuxPane(paneId: string, lines: number): string | undefined {
|
||||
try {
|
||||
// tmux capture-pane -p -t {paneId} -S -{lines}
|
||||
const output = execSync(
|
||||
`tmux capture-pane -p -t "${paneId}" -S -${lines}`,
|
||||
{ encoding: "utf-8" }
|
||||
);
|
||||
return output || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a whitelisted context object from the input context.
|
||||
* Only known fields are included to prevent accidental data leakage.
|
||||
*/
|
||||
function buildWhitelistedContext(context: OpenClawContext): OpenClawContext {
|
||||
const result: OpenClawContext = {};
|
||||
if (context.sessionId !== undefined) result.sessionId = context.sessionId;
|
||||
if (context.projectPath !== undefined)
|
||||
result.projectPath = context.projectPath;
|
||||
if (context.tmuxSession !== undefined)
|
||||
result.tmuxSession = context.tmuxSession;
|
||||
if (context.prompt !== undefined) result.prompt = context.prompt;
|
||||
if (context.contextSummary !== undefined)
|
||||
result.contextSummary = context.contextSummary;
|
||||
if (context.reason !== undefined) result.reason = context.reason;
|
||||
if (context.question !== undefined) result.question = context.question;
|
||||
if (context.tmuxTail !== undefined) result.tmuxTail = context.tmuxTail;
|
||||
if (context.replyChannel !== undefined)
|
||||
result.replyChannel = context.replyChannel;
|
||||
if (context.replyTarget !== undefined)
|
||||
result.replyTarget = context.replyTarget;
|
||||
if (context.replyThread !== undefined)
|
||||
result.replyThread = context.replyThread;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve gateway config for a specific hook event.
|
||||
* Returns null if the event is not mapped or disabled.
|
||||
* Returns the gateway name alongside config to avoid O(n) reverse lookup.
|
||||
*/
|
||||
export function resolveGateway(
|
||||
config: OpenClawConfig,
|
||||
event: OpenClawHookEvent
|
||||
): {
|
||||
gatewayName: string;
|
||||
gateway: OpenClawGatewayConfig;
|
||||
instruction: string;
|
||||
} | null {
|
||||
const mapping = config.hooks?.[event];
|
||||
if (!mapping || !mapping.enabled) {
|
||||
return null;
|
||||
}
|
||||
const gateway = config.gateways?.[mapping.gateway];
|
||||
if (!gateway) {
|
||||
return null;
|
||||
}
|
||||
// Validate based on gateway type
|
||||
if (gateway.type === "command") {
|
||||
if (!gateway.command) return null;
|
||||
} else {
|
||||
// HTTP gateway (default when type is absent or "http")
|
||||
if (!("url" in gateway) || !gateway.url) return null;
|
||||
}
|
||||
return {
|
||||
gatewayName: mapping.gateway,
|
||||
gateway,
|
||||
instruction: mapping.instruction,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wake the OpenClaw gateway mapped to a hook event.
|
||||
*
|
||||
* This is the main entry point called from the notify hook.
|
||||
* Non-blocking, swallows all errors. Returns null if OpenClaw
|
||||
* is not configured or the event is not mapped.
|
||||
*
|
||||
* @param event - The hook event type
|
||||
* @param context - Context data for template variable interpolation
|
||||
* @param config - OpenClaw configuration
|
||||
* @returns OpenClawResult or null if not configured/mapped
|
||||
*/
|
||||
export async function wakeOpenClaw(
|
||||
event: OpenClawHookEvent,
|
||||
context: OpenClawContext,
|
||||
config?: OpenClawConfig
|
||||
): Promise<OpenClawResult | null> {
|
||||
try {
|
||||
// Activation gate: only active when OMO_OPENCLAW=1
|
||||
if (process.env.OMO_OPENCLAW !== "1") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!config || !config.enabled) return null;
|
||||
|
||||
const resolved = resolveGateway(config, event);
|
||||
if (!resolved) return null;
|
||||
|
||||
const { gatewayName, gateway, instruction } = resolved;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Read originating channel context from env vars
|
||||
const replyChannel =
|
||||
context.replyChannel ?? process.env.OPENCLAW_REPLY_CHANNEL ?? undefined;
|
||||
const replyTarget =
|
||||
context.replyTarget ?? process.env.OPENCLAW_REPLY_TARGET ?? undefined;
|
||||
const replyThread =
|
||||
context.replyThread ?? process.env.OPENCLAW_REPLY_THREAD ?? undefined;
|
||||
|
||||
// Merge reply context
|
||||
const enrichedContext: OpenClawContext = {
|
||||
...context,
|
||||
...(replyChannel !== undefined && { replyChannel }),
|
||||
...(replyTarget !== undefined && { replyTarget }),
|
||||
...(replyThread !== undefined && { replyThread }),
|
||||
};
|
||||
|
||||
// Auto-detect tmux session
|
||||
const tmuxSession =
|
||||
enrichedContext.tmuxSession ?? getCurrentTmuxSession() ?? undefined;
|
||||
|
||||
// Auto-capture tmux pane content
|
||||
let tmuxTail = enrichedContext.tmuxTail;
|
||||
if (
|
||||
!tmuxTail &&
|
||||
(event === "stop" || event === "session-end") &&
|
||||
process.env.TMUX
|
||||
) {
|
||||
const paneId = process.env.TMUX_PANE;
|
||||
if (paneId) {
|
||||
tmuxTail = captureTmuxPane(paneId, 15) ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Build template variables
|
||||
const variables: Record<string, string | undefined> = {
|
||||
sessionId: enrichedContext.sessionId,
|
||||
projectPath: enrichedContext.projectPath,
|
||||
projectName: enrichedContext.projectPath
|
||||
? basename(enrichedContext.projectPath)
|
||||
: undefined,
|
||||
tmuxSession,
|
||||
prompt: enrichedContext.prompt,
|
||||
contextSummary: enrichedContext.contextSummary,
|
||||
reason: enrichedContext.reason,
|
||||
question: enrichedContext.question,
|
||||
tmuxTail,
|
||||
event,
|
||||
timestamp: now,
|
||||
replyChannel,
|
||||
replyTarget,
|
||||
replyThread,
|
||||
};
|
||||
|
||||
// Interpolate instruction
|
||||
const interpolatedInstruction = interpolateInstruction(
|
||||
instruction,
|
||||
variables
|
||||
);
|
||||
variables.instruction = interpolatedInstruction;
|
||||
|
||||
let result: OpenClawResult;
|
||||
|
||||
if (isCommandGateway(gateway)) {
|
||||
result = await wakeCommandGateway(gatewayName, gateway, variables);
|
||||
} else {
|
||||
const payload: OpenClawPayload = {
|
||||
event,
|
||||
instruction: interpolatedInstruction,
|
||||
text: interpolatedInstruction,
|
||||
timestamp: now,
|
||||
sessionId: enrichedContext.sessionId,
|
||||
projectPath: enrichedContext.projectPath,
|
||||
projectName: enrichedContext.projectPath
|
||||
? basename(enrichedContext.projectPath)
|
||||
: undefined,
|
||||
tmuxSession,
|
||||
tmuxTail,
|
||||
...(replyChannel !== undefined && { channel: replyChannel }),
|
||||
...(replyTarget !== undefined && { to: replyTarget }),
|
||||
...(replyThread !== undefined && { threadId: replyThread }),
|
||||
context: buildWhitelistedContext(enrichedContext),
|
||||
};
|
||||
result = await wakeGateway(gatewayName, gateway, payload);
|
||||
}
|
||||
|
||||
if (DEBUG) {
|
||||
console.error(
|
||||
`[openclaw] wake ${event} -> ${gatewayName}: ${
|
||||
result.success ? "ok" : result.error
|
||||
}`
|
||||
);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (DEBUG) {
|
||||
console.error(
|
||||
`[openclaw] wakeOpenClaw error:`,
|
||||
error instanceof Error ? error.message : error
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
/**
|
||||
* OpenClaw Gateway Dispatcher
|
||||
*
|
||||
* Sends instruction payloads to OpenClaw gateways via HTTP or CLI command.
|
||||
* All calls are non-blocking with timeouts. Failures are swallowed
|
||||
* to avoid blocking hooks.
|
||||
*
|
||||
* SECURITY: Command gateway requires OMO_OPENCLAW_COMMAND=1 opt-in.
|
||||
* Command timeout is configurable with safe bounds.
|
||||
* Prefers execFile for simple commands; falls back to sh -c only for shell metacharacters.
|
||||
*/
|
||||
|
||||
import {
|
||||
type OpenClawCommandGatewayConfig,
|
||||
type OpenClawGatewayConfig,
|
||||
type OpenClawHttpGatewayConfig,
|
||||
type OpenClawPayload,
|
||||
type OpenClawResult,
|
||||
} from "./types";
|
||||
import { exec, execFile } from "child_process";
|
||||
|
||||
/** Default per-request timeout for HTTP gateways */
|
||||
const DEFAULT_HTTP_TIMEOUT_MS = 10_000;
|
||||
/** Default command gateway timeout (backward-compatible default) */
|
||||
const DEFAULT_COMMAND_TIMEOUT_MS = 5_000;
|
||||
/**
|
||||
* Command timeout safety bounds.
|
||||
* - Minimum 100ms: avoids immediate/near-zero timeout misconfiguration.
|
||||
* - Maximum 300000ms (5 minutes): prevents runaway long-lived command processes.
|
||||
*/
|
||||
const MIN_COMMAND_TIMEOUT_MS = 100;
|
||||
const MAX_COMMAND_TIMEOUT_MS = 300_000;
|
||||
|
||||
/** Shell metacharacters that require sh -c instead of execFile */
|
||||
const SHELL_METACHAR_RE = /[|&;><`$()]/;
|
||||
|
||||
/**
|
||||
* Validate gateway URL. Must be HTTPS, except localhost/127.0.0.1/::1
|
||||
* which allows HTTP for local development.
|
||||
*/
|
||||
export function validateGatewayUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol === "https:") return true;
|
||||
if (
|
||||
parsed.protocol === "http:" &&
|
||||
(parsed.hostname === "localhost" ||
|
||||
parsed.hostname === "127.0.0.1" ||
|
||||
parsed.hostname === "::1" ||
|
||||
parsed.hostname === "[::1]")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (err) {
|
||||
process.stderr.write(`[openclaw-dispatcher] operation failed: ${err}\n`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate template variables in an instruction string.
|
||||
*
|
||||
* Supported variables (from hook context):
|
||||
* - {{projectName}} - basename of project directory
|
||||
* - {{projectPath}} - full project directory path
|
||||
* - {{sessionId}} - session identifier
|
||||
* - {{prompt}} - prompt text
|
||||
* - {{contextSummary}} - context summary (session-end event)
|
||||
* - {{question}} - question text (ask-user-question event)
|
||||
* - {{timestamp}} - ISO timestamp
|
||||
* - {{event}} - hook event name
|
||||
* - {{instruction}} - interpolated instruction (for command gateway)
|
||||
* - {{replyChannel}} - originating channel (from OPENCLAW_REPLY_CHANNEL env var)
|
||||
* - {{replyTarget}} - reply target user/bot (from OPENCLAW_REPLY_TARGET env var)
|
||||
* - {{replyThread}} - reply thread ID (from OPENCLAW_REPLY_THREAD env var)
|
||||
*
|
||||
* Unresolved variables are replaced with empty string.
|
||||
*/
|
||||
export function interpolateInstruction(
|
||||
template: string,
|
||||
variables: Record<string, string | undefined>
|
||||
): string {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (_match, key) => {
|
||||
return variables[key] ?? "";
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard: is this gateway config a command gateway?
|
||||
*/
|
||||
export function isCommandGateway(
|
||||
config: OpenClawGatewayConfig
|
||||
): config is OpenClawCommandGatewayConfig {
|
||||
return config.type === "command";
|
||||
}
|
||||
|
||||
/**
|
||||
* Shell-escape a string for safe embedding in a shell command.
|
||||
* Uses single-quote wrapping with internal quote escaping.
|
||||
*/
|
||||
export function shellEscapeArg(value: string): string {
|
||||
return "'" + value.replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve command gateway timeout with precedence:
|
||||
* gateway timeout > OMO_OPENCLAW_COMMAND_TIMEOUT_MS > default.
|
||||
*/
|
||||
export function resolveCommandTimeoutMs(
|
||||
gatewayTimeout?: number,
|
||||
envTimeoutRaw = process.env.OMO_OPENCLAW_COMMAND_TIMEOUT_MS
|
||||
): number {
|
||||
const parseFinite = (value: unknown): number | undefined => {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
||||
return value;
|
||||
};
|
||||
const parseEnv = (value: string | undefined): number | undefined => {
|
||||
if (!value) return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
};
|
||||
|
||||
const rawTimeout =
|
||||
parseFinite(gatewayTimeout) ??
|
||||
parseEnv(envTimeoutRaw) ??
|
||||
DEFAULT_COMMAND_TIMEOUT_MS;
|
||||
|
||||
return Math.min(
|
||||
MAX_COMMAND_TIMEOUT_MS,
|
||||
Math.max(MIN_COMMAND_TIMEOUT_MS, Math.trunc(rawTimeout))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wake an HTTP-type OpenClaw gateway with the given payload.
|
||||
*/
|
||||
export async function wakeGateway(
|
||||
gatewayName: string,
|
||||
gatewayConfig: OpenClawHttpGatewayConfig,
|
||||
payload: OpenClawPayload
|
||||
): Promise<OpenClawResult> {
|
||||
if (!validateGatewayUrl(gatewayConfig.url)) {
|
||||
return {
|
||||
gateway: gatewayName,
|
||||
success: false,
|
||||
error: "Invalid URL (HTTPS required)",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
...gatewayConfig.headers,
|
||||
};
|
||||
const timeout = gatewayConfig.timeout ?? DEFAULT_HTTP_TIMEOUT_MS;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
const response = await fetch(gatewayConfig.url, {
|
||||
method: gatewayConfig.method || "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
gateway: gatewayName,
|
||||
success: false,
|
||||
error: `HTTP ${response.status}`,
|
||||
statusCode: response.status,
|
||||
};
|
||||
}
|
||||
|
||||
return { gateway: gatewayName, success: true, statusCode: response.status };
|
||||
} catch (error) {
|
||||
return {
|
||||
gateway: gatewayName,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wake a command-type OpenClaw gateway by executing a shell command.
|
||||
*
|
||||
* SECURITY REQUIREMENTS:
|
||||
* - Requires OMO_OPENCLAW_COMMAND=1 opt-in (separate gate from OMO_OPENCLAW)
|
||||
* - Timeout is configurable via gateway.timeout or OMO_OPENCLAW_COMMAND_TIMEOUT_MS
|
||||
* with safe clamping bounds and backward-compatible default 5000ms
|
||||
* - Prefers execFile for simple commands (no metacharacters)
|
||||
* - Falls back to sh -c only when metacharacters detected
|
||||
* - detached: false to prevent orphan processes
|
||||
* - SIGTERM cleanup handler kills child on parent SIGTERM, 1s grace then SIGKILL
|
||||
*
|
||||
* The command template supports {{variable}} placeholders. All variable
|
||||
* values are shell-escaped before interpolation to prevent injection.
|
||||
*/
|
||||
export async function wakeCommandGateway(
|
||||
gatewayName: string,
|
||||
gatewayConfig: OpenClawCommandGatewayConfig,
|
||||
variables: Record<string, string | undefined>
|
||||
): Promise<OpenClawResult> {
|
||||
// Separate command gateway opt-in gate
|
||||
if (process.env.OMO_OPENCLAW_COMMAND !== "1") {
|
||||
return {
|
||||
gateway: gatewayName,
|
||||
success: false,
|
||||
error: "Command gateway disabled (set OMO_OPENCLAW_COMMAND=1 to enable)",
|
||||
};
|
||||
}
|
||||
|
||||
let child: any = null;
|
||||
let sigtermHandler: (() => void) | null = null;
|
||||
|
||||
try {
|
||||
const timeout = resolveCommandTimeoutMs(gatewayConfig.timeout);
|
||||
|
||||
// Interpolate variables with shell escaping
|
||||
const interpolated = gatewayConfig.command.replace(
|
||||
/\{\{(\w+)\}\}/g,
|
||||
(match, key) => {
|
||||
const value = variables[key];
|
||||
if (value === undefined) return match;
|
||||
return shellEscapeArg(value);
|
||||
}
|
||||
);
|
||||
|
||||
// Detect whether the interpolated command contains shell metacharacters
|
||||
const hasMetachars = SHELL_METACHAR_RE.test(interpolated);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cleanup = (signal: NodeJS.Signals) => {
|
||||
if (child) {
|
||||
child.kill(signal);
|
||||
// 1s grace period then SIGKILL
|
||||
setTimeout(() => {
|
||||
try {
|
||||
child?.kill("SIGKILL");
|
||||
} catch (err) {
|
||||
process.stderr.write(
|
||||
`[openclaw-dispatcher] operation failed: ${err}\n`
|
||||
);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
sigtermHandler = () => cleanup("SIGTERM");
|
||||
process.once("SIGTERM", sigtermHandler);
|
||||
|
||||
const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
if (sigtermHandler) {
|
||||
process.removeListener("SIGTERM", sigtermHandler);
|
||||
sigtermHandler = null;
|
||||
}
|
||||
|
||||
if (signal) {
|
||||
reject(new Error(`Command killed by signal ${signal}`));
|
||||
} else if (code !== 0) {
|
||||
reject(new Error(`Command exited with code ${code}`));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
const onError = (err: Error) => {
|
||||
if (sigtermHandler) {
|
||||
process.removeListener("SIGTERM", sigtermHandler);
|
||||
sigtermHandler = null;
|
||||
}
|
||||
reject(err);
|
||||
};
|
||||
|
||||
if (hasMetachars) {
|
||||
// Fall back to sh -c for complex commands with metacharacters
|
||||
child = exec(interpolated, {
|
||||
timeout,
|
||||
env: { ...process.env },
|
||||
});
|
||||
} else {
|
||||
// Parse simple command: split on whitespace, use execFile
|
||||
const parts = interpolated.split(/\s+/).filter(Boolean);
|
||||
const cmd = parts[0];
|
||||
const args = parts.slice(1);
|
||||
child = execFile(cmd, args, {
|
||||
timeout,
|
||||
env: { ...process.env },
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure detached is false (default, but explicit via options above)
|
||||
if (child) {
|
||||
child.on("exit", onExit);
|
||||
child.on("error", onError);
|
||||
} else {
|
||||
reject(new Error("Failed to spawn process"));
|
||||
}
|
||||
});
|
||||
|
||||
return { gateway: gatewayName, success: true };
|
||||
} catch (error) {
|
||||
// Ensure SIGTERM handler is cleaned up on error
|
||||
if (sigtermHandler) {
|
||||
process.removeListener("SIGTERM", sigtermHandler as () => void);
|
||||
}
|
||||
return {
|
||||
gateway: gatewayName,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
export { resolveGateway, wakeOpenClaw } from "./client";
|
||||
export {
|
||||
interpolateInstruction,
|
||||
isCommandGateway,
|
||||
shellEscapeArg,
|
||||
validateGatewayUrl,
|
||||
wakeCommandGateway,
|
||||
wakeGateway,
|
||||
} from "./dispatcher";
|
||||
export * from "./types";
|
||||
@@ -1,134 +0,0 @@
|
||||
/**
|
||||
* OpenClaw Gateway Integration Types
|
||||
*
|
||||
* Defines types for the OpenClaw gateway waker system.
|
||||
* Each hook event can be mapped to a gateway with a pre-defined instruction.
|
||||
*/
|
||||
|
||||
/** Hook events that can trigger OpenClaw gateway calls */
|
||||
export type OpenClawHookEvent =
|
||||
| "session-start"
|
||||
| "session-end"
|
||||
| "session-idle"
|
||||
| "ask-user-question"
|
||||
| "stop";
|
||||
|
||||
/** HTTP gateway configuration (default when type is absent or "http") */
|
||||
export interface OpenClawHttpGatewayConfig {
|
||||
/** Gateway type discriminator (optional for backward compat) */
|
||||
type?: "http";
|
||||
/** Gateway endpoint URL (HTTPS required, HTTP allowed for localhost) */
|
||||
url: string;
|
||||
/** Optional custom headers (e.g., Authorization) */
|
||||
headers?: Record<string, string>;
|
||||
/** HTTP method (default: POST) */
|
||||
method?: "POST" | "PUT";
|
||||
/** Per-request timeout in ms (default: 10000) */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
/** CLI command gateway configuration */
|
||||
export interface OpenClawCommandGatewayConfig {
|
||||
/** Gateway type discriminator */
|
||||
type: "command";
|
||||
/** Command template with {{variable}} placeholders.
|
||||
* Variables are shell-escaped automatically before interpolation. */
|
||||
command: string;
|
||||
/**
|
||||
* Per-command timeout in ms.
|
||||
* Precedence: gateway timeout > OMO_OPENCLAW_COMMAND_TIMEOUT_MS > default (5000ms).
|
||||
* Runtime clamps to safe bounds.
|
||||
*/
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
/** Gateway configuration — HTTP or CLI command */
|
||||
export type OpenClawGatewayConfig =
|
||||
| OpenClawHttpGatewayConfig
|
||||
| OpenClawCommandGatewayConfig;
|
||||
|
||||
/** Per-hook-event mapping to a gateway + instruction */
|
||||
export interface OpenClawHookMapping {
|
||||
/** Name of the gateway (key in gateways object) */
|
||||
gateway: string;
|
||||
/** Instruction template with {{variable}} placeholders */
|
||||
instruction: string;
|
||||
/** Whether this hook-event mapping is active */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/** Top-level config schema for notifications.openclaw key in .omx-config.json */
|
||||
export interface OpenClawConfig {
|
||||
/** Global enable/disable */
|
||||
enabled: boolean;
|
||||
/** Named gateway endpoints */
|
||||
gateways: Record<string, OpenClawGatewayConfig>;
|
||||
/** Hook-event to gateway+instruction mappings */
|
||||
hooks?: Partial<Record<OpenClawHookEvent, OpenClawHookMapping>>;
|
||||
}
|
||||
|
||||
/** Payload sent to an OpenClaw gateway */
|
||||
export interface OpenClawPayload {
|
||||
/** The hook event that triggered this call */
|
||||
event: OpenClawHookEvent;
|
||||
/** Interpolated instruction text */
|
||||
instruction: string;
|
||||
/** Alias of instruction — allows OpenClaw /hooks/wake to consume the payload directly */
|
||||
text: string;
|
||||
/** ISO timestamp */
|
||||
timestamp: string;
|
||||
/** Session identifier (if available) */
|
||||
sessionId?: string;
|
||||
/** Project directory path */
|
||||
projectPath?: string;
|
||||
/** Project basename */
|
||||
projectName?: string;
|
||||
/** Tmux session name (if running inside tmux) */
|
||||
tmuxSession?: string;
|
||||
/** Recent tmux pane output (for stop/session-end events) */
|
||||
tmuxTail?: string;
|
||||
/** Originating channel for reply routing (if OPENCLAW_REPLY_CHANNEL is set) */
|
||||
channel?: string;
|
||||
/** Reply target user/bot (if OPENCLAW_REPLY_TARGET is set) */
|
||||
to?: string;
|
||||
/** Reply thread ID (if OPENCLAW_REPLY_THREAD is set) */
|
||||
threadId?: string;
|
||||
/** Context data from the hook (whitelisted fields only) */
|
||||
context: OpenClawContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context data passed from the hook to OpenClaw for template interpolation.
|
||||
*
|
||||
* All fields are explicitly enumerated (no index signature) to prevent
|
||||
* accidental leakage of sensitive data into gateway payloads.
|
||||
*/
|
||||
export interface OpenClawContext {
|
||||
sessionId?: string;
|
||||
projectPath?: string;
|
||||
tmuxSession?: string;
|
||||
prompt?: string;
|
||||
contextSummary?: string;
|
||||
reason?: string;
|
||||
question?: string;
|
||||
/** Recent tmux pane output (captured automatically for stop/session-end events) */
|
||||
tmuxTail?: string;
|
||||
/** Originating channel for reply routing (from OPENCLAW_REPLY_CHANNEL env var) */
|
||||
replyChannel?: string;
|
||||
/** Reply target user/bot (from OPENCLAW_REPLY_TARGET env var) */
|
||||
replyTarget?: string;
|
||||
/** Reply thread ID for threaded conversations (from OPENCLAW_REPLY_THREAD env var) */
|
||||
replyThread?: string;
|
||||
}
|
||||
|
||||
/** Result of a gateway wake attempt */
|
||||
export interface OpenClawResult {
|
||||
/** Gateway name */
|
||||
gateway: string;
|
||||
/** Whether the call succeeded */
|
||||
success: boolean;
|
||||
/** Error message if failed */
|
||||
error?: string;
|
||||
/** HTTP status code if available */
|
||||
statusCode?: number;
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { describe, it, expect, afterEach } from "bun:test"
|
||||
|
||||
import { createEventHandler } from "./event"
|
||||
import { createChatMessageHandler } from "./chat-message"
|
||||
import { _resetForTesting, setMainSession } from "../features/claude-code-session-state"
|
||||
import { clearPendingModelFallback, createModelFallbackHook } from "../hooks/model-fallback/hook"
|
||||
|
||||
type EventInput = { event: { type: string; properties?: Record<string, unknown> } }
|
||||
type EventInput = { event: { type: string; properties?: unknown } }
|
||||
|
||||
afterEach(() => {
|
||||
_resetForTesting()
|
||||
})
|
||||
|
||||
describe("createEventHandler - idle deduplication", () => {
|
||||
it("Order A (status→idle): synthetic idle deduped - real idle not dispatched again", async () => {
|
||||
@@ -66,7 +73,7 @@ type EventInput = { event: { type: string; properties?: Record<string, unknown>
|
||||
//#then - synthetic idle dispatched once
|
||||
expect(dispatchCalls.length).toBe(1)
|
||||
expect(dispatchCalls[0].event.type).toBe("session.idle")
|
||||
expect(dispatchCalls[0].event.properties?.sessionID).toBe(sessionId)
|
||||
expect((dispatchCalls[0].event.properties as { sessionID?: string } | undefined)?.sessionID).toBe(sessionId)
|
||||
|
||||
//#when - real session.idle arrives
|
||||
await eventHandler({
|
||||
@@ -142,7 +149,7 @@ type EventInput = { event: { type: string; properties?: Record<string, unknown>
|
||||
//#then - real idle dispatched once
|
||||
expect(dispatchCalls.length).toBe(1)
|
||||
expect(dispatchCalls[0].event.type).toBe("session.idle")
|
||||
expect(dispatchCalls[0].event.properties?.sessionID).toBe(sessionId)
|
||||
expect((dispatchCalls[0].event.properties as { sessionID?: string } | undefined)?.sessionID).toBe(sessionId)
|
||||
|
||||
//#when - session.status with idle (generates synthetic idle)
|
||||
await eventHandler({
|
||||
@@ -245,7 +252,7 @@ type EventInput = { event: { type: string; properties?: Record<string, unknown>
|
||||
event: {
|
||||
type: "message.updated",
|
||||
},
|
||||
})
|
||||
} as any)
|
||||
|
||||
//#then - both maps should be pruned (no dedup should occur for new events)
|
||||
// We verify by checking that a new idle event for same session is dispatched
|
||||
@@ -287,7 +294,7 @@ type EventInput = { event: { type: string; properties?: Record<string, unknown>
|
||||
stopContinuationGuard: { event: async () => {} },
|
||||
compactionTodoPreserver: { event: async () => {} },
|
||||
atlasHook: { handler: async () => {} },
|
||||
},
|
||||
} as any,
|
||||
})
|
||||
|
||||
await eventHandlerWithMock({
|
||||
@@ -426,7 +433,7 @@ describe("createEventHandler - event forwarding", () => {
|
||||
type: "session.deleted",
|
||||
properties: { info: { id: sessionID } },
|
||||
},
|
||||
})
|
||||
} as any)
|
||||
|
||||
//#then
|
||||
expect(forwardedEvents.length).toBe(1)
|
||||
@@ -435,3 +442,146 @@ describe("createEventHandler - event forwarding", () => {
|
||||
expect(deletedSessions).toEqual([sessionID])
|
||||
})
|
||||
})
|
||||
|
||||
describe("createEventHandler - retry dedupe lifecycle", () => {
|
||||
it("re-handles same retry key after session recovers to idle status", async () => {
|
||||
//#given
|
||||
const sessionID = "ses_retry_recovery_rearm"
|
||||
setMainSession(sessionID)
|
||||
clearPendingModelFallback(sessionID)
|
||||
|
||||
const abortCalls: string[] = []
|
||||
const promptCalls: string[] = []
|
||||
const modelFallback = createModelFallbackHook()
|
||||
|
||||
const eventHandler = createEventHandler({
|
||||
ctx: {
|
||||
directory: "/tmp",
|
||||
client: {
|
||||
session: {
|
||||
abort: async ({ path }: { path: { id: string } }) => {
|
||||
abortCalls.push(path.id)
|
||||
return {}
|
||||
},
|
||||
prompt: async ({ path }: { path: { id: string } }) => {
|
||||
promptCalls.push(path.id)
|
||||
return {}
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
pluginConfig: {} as any,
|
||||
firstMessageVariantGate: {
|
||||
markSessionCreated: () => {},
|
||||
clear: () => {},
|
||||
},
|
||||
managers: {
|
||||
tmuxSessionManager: {
|
||||
onSessionCreated: async () => {},
|
||||
onSessionDeleted: async () => {},
|
||||
},
|
||||
skillMcpManager: {
|
||||
disconnectSession: async () => {},
|
||||
},
|
||||
} as any,
|
||||
hooks: {
|
||||
modelFallback,
|
||||
stopContinuationGuard: { isStopped: () => false },
|
||||
} as any,
|
||||
})
|
||||
|
||||
const chatMessageHandler = createChatMessageHandler({
|
||||
ctx: {
|
||||
client: {
|
||||
tui: {
|
||||
showToast: async () => ({}),
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
pluginConfig: {} as any,
|
||||
firstMessageVariantGate: {
|
||||
shouldOverride: () => false,
|
||||
markApplied: () => {},
|
||||
},
|
||||
hooks: {
|
||||
modelFallback,
|
||||
stopContinuationGuard: null,
|
||||
keywordDetector: null,
|
||||
claudeCodeHooks: null,
|
||||
autoSlashCommand: null,
|
||||
startWork: null,
|
||||
ralphLoop: null,
|
||||
} as any,
|
||||
})
|
||||
|
||||
const retryStatus = {
|
||||
type: "retry",
|
||||
attempt: 1,
|
||||
message: "All credentials for model claude-opus-4-6-thinking are cooling down [retrying in 7m 56s attempt #1]",
|
||||
next: 476,
|
||||
} as const
|
||||
|
||||
await eventHandler({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
id: "msg_user_retry_rearm",
|
||||
sessionID,
|
||||
role: "user",
|
||||
modelID: "claude-opus-4-6-thinking",
|
||||
providerID: "anthropic",
|
||||
agent: "Sisyphus (Ultraworker)",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any)
|
||||
|
||||
//#when - first retry key is handled
|
||||
await eventHandler({
|
||||
event: {
|
||||
type: "session.status",
|
||||
properties: {
|
||||
sessionID,
|
||||
status: retryStatus,
|
||||
},
|
||||
},
|
||||
} as any)
|
||||
|
||||
const firstOutput = { message: {}, parts: [] as Array<{ type: string; text?: string }> }
|
||||
await chatMessageHandler(
|
||||
{
|
||||
sessionID,
|
||||
agent: "sisyphus",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-6-thinking" },
|
||||
},
|
||||
firstOutput,
|
||||
)
|
||||
|
||||
//#when - session recovers to non-retry idle state
|
||||
await eventHandler({
|
||||
event: {
|
||||
type: "session.status",
|
||||
properties: {
|
||||
sessionID,
|
||||
status: { type: "idle" },
|
||||
},
|
||||
},
|
||||
} as any)
|
||||
|
||||
//#when - same retry key appears again after recovery
|
||||
await eventHandler({
|
||||
event: {
|
||||
type: "session.status",
|
||||
properties: {
|
||||
sessionID,
|
||||
status: retryStatus,
|
||||
},
|
||||
},
|
||||
} as any)
|
||||
|
||||
//#then
|
||||
expect(abortCalls).toEqual([sessionID, sessionID])
|
||||
expect(promptCalls).toEqual([sessionID, sessionID])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -215,7 +215,6 @@ export function createEventHandler(args: {
|
||||
await Promise.resolve(hooks.compactionTodoPreserver?.event?.(input));
|
||||
await Promise.resolve(hooks.writeExistingFileGuard?.event?.(input));
|
||||
await Promise.resolve(hooks.atlasHook?.handler?.(input));
|
||||
await Promise.resolve(hooks.openclawSender?.event?.(input));
|
||||
await Promise.resolve(hooks.autoSlashCommand?.event?.(input));
|
||||
};
|
||||
|
||||
@@ -422,6 +421,12 @@ export function createEventHandler(args: {
|
||||
const sessionID = props?.sessionID as string | undefined;
|
||||
const status = props?.status as { type?: string; attempt?: number; message?: string; next?: number } | undefined;
|
||||
|
||||
// Retry dedupe lifecycle: set key when a retry status is handled, clear it after recovery
|
||||
// (non-retry idle) so future failures with the same key can trigger fallback again.
|
||||
if (sessionID && status?.type === "idle") {
|
||||
lastHandledRetryStatusKey.delete(sessionID);
|
||||
}
|
||||
|
||||
if (sessionID && status?.type === "retry" && isModelFallbackEnabled && !isRuntimeFallbackEnabled) {
|
||||
try {
|
||||
const retryMessage = typeof status.message === "string" ? status.message : "";
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
createPreemptiveCompactionHook,
|
||||
createRuntimeFallbackHook,
|
||||
} from "../../hooks"
|
||||
import { createOpenClawSenderHook } from "../../hooks/openclaw-sender"
|
||||
import { createAnthropicEffortHook } from "../../hooks/anthropic-effort"
|
||||
import {
|
||||
detectExternalNotificationPlugin,
|
||||
@@ -61,7 +60,6 @@ export type SessionHooks = {
|
||||
taskResumeInfo: ReturnType<typeof createTaskResumeInfoHook> | null
|
||||
anthropicEffort: ReturnType<typeof createAnthropicEffortHook> | null
|
||||
runtimeFallback: ReturnType<typeof createRuntimeFallbackHook> | null
|
||||
openclawSender: ReturnType<typeof createOpenClawSenderHook> | null
|
||||
}
|
||||
|
||||
export function createSessionHooks(args: {
|
||||
@@ -263,11 +261,6 @@ export function createSessionHooks(args: {
|
||||
pluginConfig,
|
||||
}))
|
||||
: null
|
||||
|
||||
const openclawSender = isHookEnabled("openclaw-sender") && pluginConfig.openclaw?.enabled
|
||||
? safeHook("openclaw-sender", () => createOpenClawSenderHook(ctx, pluginConfig.openclaw!))
|
||||
: null
|
||||
|
||||
return {
|
||||
contextWindowMonitor,
|
||||
preemptiveCompaction,
|
||||
@@ -292,6 +285,5 @@ export function createSessionHooks(args: {
|
||||
taskResumeInfo,
|
||||
anthropicEffort,
|
||||
runtimeFallback,
|
||||
openclawSender,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,22 +48,50 @@ export function createToolExecuteAfterHandler(args: {
|
||||
const prompt = typeof output.metadata?.prompt === "string" ? output.metadata.prompt : undefined
|
||||
const verificationAttemptId = prompt?.match(VERIFICATION_ATTEMPT_PATTERN)?.[1]?.trim()
|
||||
const loopState = directory ? readState(directory) : null
|
||||
|
||||
if (
|
||||
const isVerificationContext =
|
||||
agent === "oracle"
|
||||
&& sessionId
|
||||
&& verificationAttemptId
|
||||
&& directory
|
||||
&& !!sessionId
|
||||
&& !!directory
|
||||
&& loopState?.active === true
|
||||
&& loopState.ultrawork === true
|
||||
&& loopState.verification_pending === true
|
||||
&& loopState.session_id === input.sessionID
|
||||
|
||||
log("[tool-execute-after] ULW verification tracking check", {
|
||||
tool: input.tool,
|
||||
agent,
|
||||
parentSessionID: input.sessionID,
|
||||
oracleSessionID: sessionId,
|
||||
hasPromptInMetadata: typeof prompt === "string",
|
||||
extractedVerificationAttemptId: verificationAttemptId,
|
||||
})
|
||||
|
||||
if (
|
||||
isVerificationContext
|
||||
&& verificationAttemptId
|
||||
&& loopState.verification_attempt_id === verificationAttemptId
|
||||
) {
|
||||
writeState(directory, {
|
||||
...loopState,
|
||||
verification_session_id: sessionId,
|
||||
})
|
||||
log("[tool-execute-after] Stored oracle verification session via attempt match", {
|
||||
parentSessionID: input.sessionID,
|
||||
oracleSessionID: sessionId,
|
||||
verificationAttemptId,
|
||||
})
|
||||
} else if (isVerificationContext && !verificationAttemptId) {
|
||||
writeState(directory, {
|
||||
...loopState,
|
||||
verification_session_id: sessionId,
|
||||
})
|
||||
log("[tool-execute-after] Fallback: stored oracle verification session without attempt match", {
|
||||
parentSessionID: input.sessionID,
|
||||
oracleSessionID: sessionId,
|
||||
hasPromptInMetadata: typeof prompt === "string",
|
||||
expectedAttemptId: loopState.verification_attempt_id,
|
||||
extractedAttemptId: verificationAttemptId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ export function createToolExecuteBeforeHandler(args: {
|
||||
await hooks.prometheusMdOnly?.["tool.execute.before"]?.(input, output)
|
||||
await hooks.sisyphusJuniorNotepad?.["tool.execute.before"]?.(input, output)
|
||||
await hooks.atlasHook?.["tool.execute.before"]?.(input, output)
|
||||
await hooks.openclawSender?.["tool.execute.before"]?.(input, output)
|
||||
|
||||
const normalizedToolName = input.tool.toLowerCase()
|
||||
if (
|
||||
@@ -80,6 +79,12 @@ export function createToolExecuteBeforeHandler(args: {
|
||||
|
||||
if (shouldInjectOracleVerification) {
|
||||
const verificationAttemptId = randomUUID()
|
||||
log("[tool-execute-before] Injecting ULW oracle verification attempt", {
|
||||
sessionID: input.sessionID,
|
||||
callID: input.callID,
|
||||
verificationAttemptId,
|
||||
loopSessionID: loopState.session_id,
|
||||
})
|
||||
writeState(ctx.directory, {
|
||||
...loopState,
|
||||
verification_attempt_id: verificationAttemptId,
|
||||
|
||||
@@ -19,6 +19,27 @@ describe("tool.execute.before ultrawork oracle verification", () => {
|
||||
}
|
||||
}
|
||||
|
||||
function createOracleTaskArgs(prompt: string): Record<string, unknown> {
|
||||
return {
|
||||
subagent_type: "oracle",
|
||||
run_in_background: true,
|
||||
prompt,
|
||||
}
|
||||
}
|
||||
|
||||
function createSyncTaskMetadata(
|
||||
args: Record<string, unknown>,
|
||||
sessionId: string,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
prompt: args.prompt,
|
||||
agent: "oracle",
|
||||
run_in_background: args.run_in_background,
|
||||
sessionId,
|
||||
sync: true,
|
||||
}
|
||||
}
|
||||
|
||||
test("#given ulw loop is awaiting verification #when oracle task runs #then oracle prompt is enforced and sync", async () => {
|
||||
const directory = join(tmpdir(), `tool-before-ulw-${Date.now()}`)
|
||||
mkdirSync(directory, { recursive: true })
|
||||
@@ -38,13 +59,7 @@ describe("tool.execute.before ultrawork oracle verification", () => {
|
||||
ctx: createCtx(directory) as unknown as Parameters<typeof createToolExecuteBeforeHandler>[0]["ctx"],
|
||||
hooks: {} as Parameters<typeof createToolExecuteBeforeHandler>[0]["hooks"],
|
||||
})
|
||||
const output = {
|
||||
args: {
|
||||
subagent_type: "oracle",
|
||||
run_in_background: true,
|
||||
prompt: "Check it",
|
||||
} as Record<string, unknown>,
|
||||
}
|
||||
const output = { args: createOracleTaskArgs("Check it") }
|
||||
|
||||
await handler({ tool: "task", sessionID: "ses-main", callID: "call-1" }, output)
|
||||
|
||||
@@ -64,13 +79,7 @@ describe("tool.execute.before ultrawork oracle verification", () => {
|
||||
ctx: createCtx(directory) as unknown as Parameters<typeof createToolExecuteBeforeHandler>[0]["ctx"],
|
||||
hooks: {} as Parameters<typeof createToolExecuteBeforeHandler>[0]["hooks"],
|
||||
})
|
||||
const output = {
|
||||
args: {
|
||||
subagent_type: "oracle",
|
||||
run_in_background: true,
|
||||
prompt: "Check it",
|
||||
} as Record<string, unknown>,
|
||||
}
|
||||
const output = { args: createOracleTaskArgs("Check it") }
|
||||
|
||||
await handler({ tool: "task", sessionID: "ses-main", callID: "call-1" }, output)
|
||||
|
||||
@@ -80,7 +89,7 @@ describe("tool.execute.before ultrawork oracle verification", () => {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test("#given ulw loop is awaiting verification #when oracle task finishes #then oracle session id is stored", async () => {
|
||||
test("#given ulw loop is awaiting verification #when oracle sync task metadata is persisted #then oracle session id is stored", async () => {
|
||||
const directory = join(tmpdir(), `tool-after-ulw-${Date.now()}`)
|
||||
mkdirSync(directory, { recursive: true })
|
||||
writeState(directory, {
|
||||
@@ -99,14 +108,44 @@ describe("tool.execute.before ultrawork oracle verification", () => {
|
||||
ctx: createCtx(directory) as unknown as Parameters<typeof createToolExecuteBeforeHandler>[0]["ctx"],
|
||||
hooks: {} as Parameters<typeof createToolExecuteBeforeHandler>[0]["hooks"],
|
||||
})
|
||||
const beforeOutput = {
|
||||
args: {
|
||||
subagent_type: "oracle",
|
||||
run_in_background: true,
|
||||
prompt: "Check it",
|
||||
} as Record<string, unknown>,
|
||||
}
|
||||
const beforeOutput = { args: createOracleTaskArgs("Check it") }
|
||||
await beforeHandler({ tool: "task", sessionID: "ses-main", callID: "call-1" }, beforeOutput)
|
||||
const metadataFromSyncTask = createSyncTaskMetadata(beforeOutput.args, "ses-oracle")
|
||||
|
||||
const handler = createToolExecuteAfterHandler({
|
||||
ctx: createCtx(directory) as unknown as Parameters<typeof createToolExecuteAfterHandler>[0]["ctx"],
|
||||
hooks: {} as Parameters<typeof createToolExecuteAfterHandler>[0]["hooks"],
|
||||
})
|
||||
|
||||
await handler(
|
||||
{ tool: "task", sessionID: "ses-main", callID: "call-1" },
|
||||
{
|
||||
title: "oracle task",
|
||||
output: "done",
|
||||
metadata: metadataFromSyncTask,
|
||||
},
|
||||
)
|
||||
|
||||
expect(readState(directory)?.verification_session_id).toBe("ses-oracle")
|
||||
|
||||
clearState(directory)
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test("#given ulw loop is awaiting verification #when oracle metadata prompt is missing #then oracle session fallback is stored", async () => {
|
||||
const directory = join(tmpdir(), `tool-after-ulw-fallback-${Date.now()}`)
|
||||
mkdirSync(directory, { recursive: true })
|
||||
writeState(directory, {
|
||||
active: true,
|
||||
iteration: 3,
|
||||
completion_promise: ULTRAWORK_VERIFICATION_PROMISE,
|
||||
initial_completion_promise: "DONE",
|
||||
started_at: new Date().toISOString(),
|
||||
prompt: "Ship feature",
|
||||
session_id: "ses-main",
|
||||
ultrawork: true,
|
||||
verification_pending: true,
|
||||
})
|
||||
|
||||
const handler = createToolExecuteAfterHandler({
|
||||
ctx: createCtx(directory) as unknown as Parameters<typeof createToolExecuteAfterHandler>[0]["ctx"],
|
||||
@@ -120,13 +159,13 @@ describe("tool.execute.before ultrawork oracle verification", () => {
|
||||
output: "done",
|
||||
metadata: {
|
||||
agent: "oracle",
|
||||
prompt: String(beforeOutput.args.prompt),
|
||||
sessionId: "ses-oracle",
|
||||
sessionId: "ses-oracle-fallback",
|
||||
sync: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(readState(directory)?.verification_session_id).toBe("ses-oracle")
|
||||
expect(readState(directory)?.verification_session_id).toBe("ses-oracle-fallback")
|
||||
|
||||
clearState(directory)
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
@@ -156,23 +195,11 @@ describe("tool.execute.before ultrawork oracle verification", () => {
|
||||
hooks: {} as Parameters<typeof createToolExecuteAfterHandler>[0]["hooks"],
|
||||
})
|
||||
|
||||
const firstOutput = {
|
||||
args: {
|
||||
subagent_type: "oracle",
|
||||
run_in_background: true,
|
||||
prompt: "Check it",
|
||||
} as Record<string, unknown>,
|
||||
}
|
||||
const firstOutput = { args: createOracleTaskArgs("Check it") }
|
||||
await beforeHandler({ tool: "task", sessionID: "ses-main", callID: "call-1" }, firstOutput)
|
||||
const firstAttemptId = readState(directory)?.verification_attempt_id
|
||||
|
||||
const secondOutput = {
|
||||
args: {
|
||||
subagent_type: "oracle",
|
||||
run_in_background: true,
|
||||
prompt: "Check it again",
|
||||
} as Record<string, unknown>,
|
||||
}
|
||||
const secondOutput = { args: createOracleTaskArgs("Check it again") }
|
||||
await beforeHandler({ tool: "task", sessionID: "ses-main", callID: "call-2" }, secondOutput)
|
||||
const secondAttemptId = readState(directory)?.verification_attempt_id
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const PLUGIN_NAME = "oh-my-opencode"
|
||||
export const LEGACY_PLUGIN_NAME = "oh-my-openagent"
|
||||
export const CONFIG_BASENAME = "oh-my-opencode"
|
||||
export const LOG_FILENAME = "oh-my-opencode.log"
|
||||
export const CACHE_DIR_NAME = "oh-my-opencode"
|
||||
|
||||
@@ -109,3 +109,44 @@ export function buildEnvPrefix(
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a value for use in a double-quoted shell -c command argument.
|
||||
*
|
||||
* In shell -c "..." strings, these characters have special meaning and must be escaped:
|
||||
* - $ - variable expansion, command substitution $(...)
|
||||
* - ` - command substitution `...`
|
||||
* - \\ - escape character
|
||||
* - " - end quote
|
||||
* - ; | & - command separators
|
||||
* - # - comment
|
||||
* - () - grouping operators
|
||||
*
|
||||
* @param value - The value to escape
|
||||
* @returns Escaped value safe for double-quoted shell -c argument
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // For malicious input
|
||||
* const url = "http://localhost:3000'; cat /etc/passwd; echo '"
|
||||
* const escaped = shellEscapeForDoubleQuotedCommand(url)
|
||||
* // => "http://localhost:3000'\''; cat /etc/passwd; echo '"
|
||||
*
|
||||
* // Usage in command:
|
||||
* const cmd = `/bin/sh -c "opencode attach ${escaped} --session ${sessionId}"`
|
||||
* ```
|
||||
*/
|
||||
export function shellEscapeForDoubleQuotedCommand(value: string): string {
|
||||
// Order matters: escape backslash FIRST, then other characters
|
||||
return value
|
||||
.replace(/\\/g, "\\\\") // escape backslash first
|
||||
.replace(/\$/g, "\\$") // escape dollar sign
|
||||
.replace(/`/g, "\\`") // escape backticks
|
||||
.replace(/"/g, "\\\"") // escape double quotes
|
||||
.replace(/;/g, "\\;") // escape semicolon (command separator)
|
||||
.replace(/\|/g, "\\|") // escape pipe (command separator)
|
||||
.replace(/&/g, "\\&") // escape ampersand (command separator)
|
||||
.replace(/#/g, "\\#") // escape hash (comment)
|
||||
.replace(/\(/g, "\\(") // escape parentheses
|
||||
.replace(/\)/g, "\\)") // escape parentheses
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TmuxConfig } from "../../../config/schema"
|
||||
import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver"
|
||||
import type { SpawnPaneResult } from "../types"
|
||||
import { isInsideTmux } from "./environment"
|
||||
import { shellEscapeForDoubleQuotedCommand } from "../../shell-env"
|
||||
|
||||
export async function replaceTmuxPane(
|
||||
paneId: string,
|
||||
@@ -35,7 +36,8 @@ export async function replaceTmuxPane(
|
||||
await ctrlCProc.exited
|
||||
|
||||
const shell = process.env.SHELL || "/bin/sh"
|
||||
const opencodeCmd = `${shell} -c 'opencode attach ${serverUrl} --session ${sessionId}'`
|
||||
const escapedUrl = shellEscapeForDoubleQuotedCommand(serverUrl)
|
||||
const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${sessionId}"`
|
||||
|
||||
const proc = spawn([tmux, "respawn-pane", "-k", "-t", paneId, opencodeCmd], {
|
||||
stdout: "pipe",
|
||||
@@ -60,6 +62,7 @@ export async function replaceTmuxPane(
|
||||
const titleStderr = await stderrPromise
|
||||
log("[replaceTmuxPane] WARNING: failed to set pane title", {
|
||||
paneId,
|
||||
title,
|
||||
exitCode: titleExitCode,
|
||||
stderr: titleStderr.trim(),
|
||||
})
|
||||
|
||||
96
src/shared/tmux/tmux-utils/pane-spawn.test.ts
Normal file
96
src/shared/tmux/tmux-utils/pane-spawn.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { shellEscapeForDoubleQuotedCommand } from "../../shell-env"
|
||||
|
||||
describe("given a serverUrl with shell metacharacters", () => {
|
||||
describe("when building tmux spawn command with double quotes", () => {
|
||||
it("then serverUrl is escaped to prevent shell injection", () => {
|
||||
const serverUrl = "http://localhost:3000'; cat /etc/passwd; echo '"
|
||||
const sessionId = "test-session"
|
||||
const shell = "/bin/sh"
|
||||
|
||||
// Use double quotes for outer shell -c command, escape dangerous chars in URL
|
||||
const escapedUrl = shellEscapeForDoubleQuotedCommand(serverUrl)
|
||||
const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${sessionId}"`
|
||||
|
||||
// The semicolon should be escaped so it's treated as literal, not separator
|
||||
expect(opencodeCmd).toContain("\\;")
|
||||
// The malicious content should be escaped - semicolons are now \\;
|
||||
expect(opencodeCmd).not.toMatch(/[^\\];\s*cat/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("when building tmux replace command", () => {
|
||||
it("then serverUrl is escaped to prevent shell injection", () => {
|
||||
const serverUrl = "http://localhost:3000'; rm -rf /; '"
|
||||
const sessionId = "test-session"
|
||||
const shell = "/bin/sh"
|
||||
|
||||
const escapedUrl = shellEscapeForDoubleQuotedCommand(serverUrl)
|
||||
const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${sessionId}"`
|
||||
|
||||
expect(opencodeCmd).toContain("\\;")
|
||||
expect(opencodeCmd).not.toMatch(/[^\\];\s*rm/)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("given a normal serverUrl without shell metacharacters", () => {
|
||||
describe("when building tmux spawn command", () => {
|
||||
it("then serverUrl works correctly", () => {
|
||||
const serverUrl = "http://localhost:3000"
|
||||
const sessionId = "test-session"
|
||||
const shell = "/bin/sh"
|
||||
|
||||
const escapedUrl = shellEscapeForDoubleQuotedCommand(serverUrl)
|
||||
const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${sessionId}"`
|
||||
|
||||
expect(opencodeCmd).toContain(serverUrl)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("given a serverUrl with dollar sign (command injection)", () => {
|
||||
describe("when building tmux command", () => {
|
||||
it("then dollar sign is escaped properly", () => {
|
||||
const serverUrl = "http://localhost:3000$(whoami)"
|
||||
const sessionId = "test-session"
|
||||
const shell = "/bin/sh"
|
||||
|
||||
const escapedUrl = shellEscapeForDoubleQuotedCommand(serverUrl)
|
||||
const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${sessionId}"`
|
||||
|
||||
// The $ should be escaped to literal $
|
||||
expect(opencodeCmd).toContain("\\$")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("given a serverUrl with backticks (command injection)", () => {
|
||||
describe("when building tmux command", () => {
|
||||
it("then backticks are escaped properly", () => {
|
||||
const serverUrl = "http://localhost:3000`whoami`"
|
||||
const sessionId = "test-session"
|
||||
const shell = "/bin/sh"
|
||||
|
||||
const escapedUrl = shellEscapeForDoubleQuotedCommand(serverUrl)
|
||||
const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${sessionId}"`
|
||||
|
||||
expect(opencodeCmd).toContain("\\`")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("given a serverUrl with pipe operator", () => {
|
||||
describe("when building tmux command", () => {
|
||||
it("then pipe is escaped properly", () => {
|
||||
const serverUrl = "http://localhost:3000 | ls"
|
||||
const sessionId = "test-session"
|
||||
const shell = "/bin/sh"
|
||||
|
||||
const escapedUrl = shellEscapeForDoubleQuotedCommand(serverUrl)
|
||||
const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${sessionId}"`
|
||||
|
||||
expect(opencodeCmd).toContain("\\|")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import type { SpawnPaneResult } from "../types"
|
||||
import type { SplitDirection } from "./environment"
|
||||
import { isInsideTmux } from "./environment"
|
||||
import { isServerRunning } from "./server-health"
|
||||
import { shellEscapeForDoubleQuotedCommand } from "../../shell-env"
|
||||
|
||||
export async function spawnTmuxPane(
|
||||
sessionId: string,
|
||||
@@ -49,7 +50,8 @@ export async function spawnTmuxPane(
|
||||
log("[spawnTmuxPane] all checks passed, spawning...")
|
||||
|
||||
const shell = process.env.SHELL || "/bin/sh"
|
||||
const opencodeCmd = `${shell} -c 'opencode attach ${serverUrl} --session ${sessionId}'`
|
||||
const escapedUrl = shellEscapeForDoubleQuotedCommand(serverUrl)
|
||||
const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${sessionId}"`
|
||||
|
||||
const args = [
|
||||
"split-window",
|
||||
|
||||
@@ -7,16 +7,22 @@ import * as connectedProvidersCache from "../../shared/connected-providers-cache
|
||||
describe("resolveCategoryExecution", () => {
|
||||
let connectedProvidersSpy: ReturnType<typeof spyOn> | undefined
|
||||
let providerModelsSpy: ReturnType<typeof spyOn> | undefined
|
||||
let hasConnectedProvidersSpy: ReturnType<typeof spyOn> | undefined
|
||||
let hasProviderModelsSpy: ReturnType<typeof spyOn> | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
mock.restore()
|
||||
connectedProvidersSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
||||
providerModelsSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue(null)
|
||||
hasConnectedProvidersSpy = spyOn(connectedProvidersCache, "hasConnectedProvidersCache").mockReturnValue(false)
|
||||
hasProviderModelsSpy = spyOn(connectedProvidersCache, "hasProviderModelsCache").mockReturnValue(false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
connectedProvidersSpy?.mockRestore()
|
||||
providerModelsSpy?.mockRestore()
|
||||
hasConnectedProvidersSpy?.mockRestore()
|
||||
hasProviderModelsSpy?.mockRestore()
|
||||
})
|
||||
|
||||
const createMockExecutorContext = (): ExecutorContext => ({
|
||||
@@ -27,7 +33,7 @@ describe("resolveCategoryExecution", () => {
|
||||
sisyphusJuniorModel: undefined,
|
||||
})
|
||||
|
||||
test("returns clear error when category exists but required model is not available", async () => {
|
||||
test("returns unpinned resolution when category cache is not ready on first run", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
category: "deep",
|
||||
@@ -39,6 +45,9 @@ describe("resolveCategoryExecution", () => {
|
||||
enableSkillTools: false,
|
||||
}
|
||||
const executorCtx = createMockExecutorContext()
|
||||
executorCtx.userCategories = {
|
||||
deep: {},
|
||||
}
|
||||
const inheritedModel = undefined
|
||||
const systemDefaultModel = "anthropic/claude-sonnet-4-6"
|
||||
|
||||
@@ -46,10 +55,10 @@ describe("resolveCategoryExecution", () => {
|
||||
const result = await resolveCategoryExecution(args, executorCtx, inheritedModel, systemDefaultModel)
|
||||
|
||||
//#then
|
||||
expect(result.error).toBeDefined()
|
||||
expect(result.error).toContain("deep")
|
||||
expect(result.error).toMatch(/model.*not.*available|requires.*model/i)
|
||||
expect(result.error).not.toContain("Unknown category")
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.actualModel).toBeUndefined()
|
||||
expect(result.categoryModel).toBeUndefined()
|
||||
expect(result.agentToUse).toBeDefined()
|
||||
})
|
||||
|
||||
test("returns 'unknown category' error for truly unknown categories", async () => {
|
||||
|
||||
@@ -85,6 +85,7 @@ Available categories: ${allCategoryNames}`,
|
||||
let actualModel: string | undefined
|
||||
let modelInfo: ModelFallbackInfo | undefined
|
||||
let categoryModel: { providerID: string; modelID: string; variant?: string } | undefined
|
||||
let isModelResolutionSkipped = false
|
||||
|
||||
const overrideModel = sisyphusJuniorModel
|
||||
const explicitCategoryModel = userCategories?.[args.category!]?.model
|
||||
@@ -98,6 +99,11 @@ Available categories: ${allCategoryNames}`,
|
||||
modelInfo = explicitCategoryModel || overrideModel
|
||||
? { model: actualModel, type: "user-defined", source: "override" }
|
||||
: { model: actualModel, type: "system-default", source: "system-default" }
|
||||
const parsedModel = parseModelString(actualModel)
|
||||
const variantToUse = userCategories?.[args.category!]?.variant ?? resolved.config.variant
|
||||
categoryModel = parsedModel
|
||||
? (variantToUse ? { ...parsedModel, variant: variantToUse } : parsedModel)
|
||||
: undefined
|
||||
}
|
||||
} else {
|
||||
const resolution = resolveModelForDelegateTask({
|
||||
@@ -109,7 +115,9 @@ Available categories: ${allCategoryNames}`,
|
||||
systemDefaultModel,
|
||||
})
|
||||
|
||||
if (resolution) {
|
||||
if (resolution && "skipped" in resolution) {
|
||||
isModelResolutionSkipped = true
|
||||
} else if (resolution) {
|
||||
const { model: resolvedModel, variant: resolvedVariant } = resolution
|
||||
actualModel = resolvedModel
|
||||
|
||||
@@ -156,7 +164,7 @@ Available categories: ${allCategoryNames}`,
|
||||
}
|
||||
const categoryPromptAppend = resolved.promptAppend || undefined
|
||||
|
||||
if (!categoryModel && !actualModel) {
|
||||
if (!categoryModel && !actualModel && !isModelResolutionSkipped) {
|
||||
const categoryNames = Object.keys(enabledCategories)
|
||||
return {
|
||||
agentToUse: "",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
declare const require: (name: string) => any
|
||||
const { afterEach, beforeEach, describe, expect, mock, spyOn, test } = require("bun:test")
|
||||
import { resolveModelForDelegateTask } from "./model-selection"
|
||||
import * as connectedProvidersCache from "../../shared/connected-providers-cache"
|
||||
|
||||
@@ -22,7 +23,7 @@ describe("resolveModelForDelegateTask", () => {
|
||||
})
|
||||
|
||||
describe("#when availableModels is empty and no user model override", () => {
|
||||
test("#then returns undefined to let OpenCode use system default", () => {
|
||||
test("#then returns skipped sentinel to leave model unpinned", () => {
|
||||
const result = resolveModelForDelegateTask({
|
||||
categoryDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||
fallbackChain: [
|
||||
@@ -32,7 +33,7 @@ describe("resolveModelForDelegateTask", () => {
|
||||
systemDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||
})
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
expect(result).toEqual({ skipped: true })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,7 +54,7 @@ describe("resolveModelForDelegateTask", () => {
|
||||
})
|
||||
|
||||
describe("#when user set fallback_models but no cache exists", () => {
|
||||
test("#then returns undefined (skip fallback resolution without cache)", () => {
|
||||
test("#then returns skipped sentinel (skip fallback resolution without cache)", () => {
|
||||
const result = resolveModelForDelegateTask({
|
||||
userFallbackModels: ["openai/gpt-5.4", "google/gemini-3.1-pro"],
|
||||
categoryDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||
@@ -63,7 +64,7 @@ describe("resolveModelForDelegateTask", () => {
|
||||
availableModels: new Set(),
|
||||
})
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
expect(result).toEqual({ skipped: true })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -85,8 +86,7 @@ describe("resolveModelForDelegateTask", () => {
|
||||
systemDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||
})
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.model).toBe("anthropic/claude-sonnet-4-6")
|
||||
expect(result).toEqual({ model: "anthropic/claude-sonnet-4-6" })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -100,8 +100,7 @@ describe("resolveModelForDelegateTask", () => {
|
||||
availableModels: new Set(["anthropic/claude-sonnet-4-6"]),
|
||||
})
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.model).toBe("anthropic/claude-sonnet-4-6")
|
||||
expect(result).toEqual({ model: "anthropic/claude-sonnet-4-6" })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ export function resolveModelForDelegateTask(input: {
|
||||
fallbackChain?: FallbackEntry[]
|
||||
availableModels: Set<string>
|
||||
systemDefaultModel?: string
|
||||
}): { model: string; variant?: string } | undefined {
|
||||
}): { model: string; variant?: string } | { skipped: true } | undefined {
|
||||
const userModel = normalizeModel(input.userModel)
|
||||
if (userModel) {
|
||||
return { model: userModel }
|
||||
@@ -60,7 +60,7 @@ export function resolveModelForDelegateTask(input: {
|
||||
// Before provider cache is created (first run), skip model resolution entirely.
|
||||
// OpenCode will use its system default model when no model is specified in the prompt.
|
||||
if (input.availableModels.size === 0 && !hasProviderModelsCache() && !hasConnectedProvidersCache()) {
|
||||
return undefined
|
||||
return { skipped: true }
|
||||
}
|
||||
|
||||
const categoryDefault = normalizeModel(input.categoryDefaultModel)
|
||||
|
||||
@@ -124,7 +124,7 @@ Create the work plan directly - that's your job as the planning agent.`,
|
||||
systemDefaultModel: undefined,
|
||||
})
|
||||
|
||||
if (resolution) {
|
||||
if (resolution && !('skipped' in resolution)) {
|
||||
const normalized = normalizeModelFormat(resolution.model)
|
||||
if (normalized) {
|
||||
const variantToUse = agentOverride?.variant ?? resolution.variant
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
declare const require: (name: string) => any
|
||||
const { describe, expect, test } = require("bun:test")
|
||||
import { __resetTimingConfig, __setTimingConfig, getDefaultSyncPollTimeoutMs } from "./timing"
|
||||
import { __resetTimingConfig, __setTimingConfig, getDefaultSyncPollTimeoutMs, getTimingConfig } from "./timing"
|
||||
|
||||
describe("timing sync poll timeout defaults", () => {
|
||||
test("default sync timeout is 30 minutes", () => {
|
||||
@@ -27,3 +27,16 @@ describe("timing sync poll timeout defaults", () => {
|
||||
__resetTimingConfig()
|
||||
})
|
||||
})
|
||||
|
||||
describe("WAIT_FOR_SESSION_TIMEOUT_MS default", () => {
|
||||
test("default wait for session timeout is 1 minute", () => {
|
||||
// #given
|
||||
__resetTimingConfig()
|
||||
|
||||
// #when
|
||||
const config = getTimingConfig()
|
||||
|
||||
// #then
|
||||
expect(config.WAIT_FOR_SESSION_TIMEOUT_MS).toBe(60_000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ let POLL_INTERVAL_MS = 1000
|
||||
let MIN_STABILITY_TIME_MS = 10000
|
||||
let STABILITY_POLLS_REQUIRED = 3
|
||||
let WAIT_FOR_SESSION_INTERVAL_MS = 100
|
||||
let WAIT_FOR_SESSION_TIMEOUT_MS = 30000
|
||||
let WAIT_FOR_SESSION_TIMEOUT_MS = 60000
|
||||
const DEFAULT_POLL_TIMEOUT_MS = 30 * 60 * 1000
|
||||
let MAX_POLL_TIME_MS = DEFAULT_POLL_TIMEOUT_MS
|
||||
let SESSION_CONTINUATION_STABILITY_MS = 5000
|
||||
@@ -30,7 +30,7 @@ export function __resetTimingConfig(): void {
|
||||
MIN_STABILITY_TIME_MS = 10000
|
||||
STABILITY_POLLS_REQUIRED = 3
|
||||
WAIT_FOR_SESSION_INTERVAL_MS = 100
|
||||
WAIT_FOR_SESSION_TIMEOUT_MS = 30000
|
||||
WAIT_FOR_SESSION_TIMEOUT_MS = 60000
|
||||
MAX_POLL_TIME_MS = DEFAULT_POLL_TIMEOUT_MS
|
||||
SESSION_CONTINUATION_STABILITY_MS = 5000
|
||||
}
|
||||
|
||||
@@ -1258,6 +1258,211 @@ describe("sisyphus-task", () => {
|
||||
}, { timeout: 20000 })
|
||||
})
|
||||
|
||||
describe("run_in_background parameter", () => {
|
||||
test("#given category without run_in_background #when executing #then throws required parameter error", async () => {
|
||||
// given
|
||||
const { createDelegateTask } = require("./tools")
|
||||
const mockManager = { launch: async () => ({}) }
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
session: {
|
||||
create: async () => ({ data: { id: "test-session" } }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
promptAsync: async () => ({ data: {} }),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
const tool = createDelegateTask({ manager: mockManager, client: mockClient })
|
||||
|
||||
// when
|
||||
// then
|
||||
await expect(tool.execute(
|
||||
{
|
||||
description: "Category without run flag",
|
||||
prompt: "Do something",
|
||||
category: "quick",
|
||||
load_skills: [],
|
||||
},
|
||||
{ sessionID: "parent-session", messageID: "parent-message", agent: "sisyphus", abort: new AbortController().signal }
|
||||
)).rejects.toThrow("Invalid arguments: 'run_in_background' parameter is REQUIRED")
|
||||
})
|
||||
|
||||
test("#given subagent_type without run_in_background #when executing #then throws required parameter error", async () => {
|
||||
// given
|
||||
const { createDelegateTask } = require("./tools")
|
||||
const mockManager = { launch: async () => ({}) }
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [{ name: "explore", mode: "subagent" }] }) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
session: {
|
||||
create: async () => ({ data: { id: "test-session" } }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
promptAsync: async () => ({ data: {} }),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
const tool = createDelegateTask({ manager: mockManager, client: mockClient })
|
||||
|
||||
// when
|
||||
// then
|
||||
await expect(tool.execute(
|
||||
{
|
||||
description: "Subagent without run flag",
|
||||
prompt: "Find patterns",
|
||||
subagent_type: "explore",
|
||||
load_skills: [],
|
||||
},
|
||||
{ sessionID: "parent-session", messageID: "parent-message", agent: "sisyphus", abort: new AbortController().signal }
|
||||
)).rejects.toThrow("Invalid arguments: 'run_in_background' parameter is REQUIRED")
|
||||
})
|
||||
|
||||
test("#given session_id without run_in_background #when executing #then throws required parameter error", async () => {
|
||||
// given
|
||||
const { createDelegateTask } = require("./tools")
|
||||
const mockManager = { resume: async () => ({ id: "task-1", sessionID: "ses_1", status: "running" }) }
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
session: {
|
||||
create: async () => ({ data: { id: "test-session" } }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
promptAsync: async () => ({ data: {} }),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
const tool = createDelegateTask({ manager: mockManager, client: mockClient })
|
||||
|
||||
// when
|
||||
// then
|
||||
await expect(tool.execute(
|
||||
{
|
||||
description: "Continue without run flag",
|
||||
prompt: "Continue",
|
||||
session_id: "ses_existing",
|
||||
load_skills: [],
|
||||
},
|
||||
{ sessionID: "parent-session", messageID: "parent-message", agent: "sisyphus", abort: new AbortController().signal }
|
||||
)).rejects.toThrow("Invalid arguments: 'run_in_background' parameter is REQUIRED")
|
||||
})
|
||||
|
||||
test("#given no category no subagent_type no session_id and no run_in_background #when executing #then throws required parameter error", async () => {
|
||||
// given
|
||||
const { createDelegateTask } = require("./tools")
|
||||
const mockManager = { launch: async () => ({}) }
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
session: {
|
||||
create: async () => ({ data: { id: "test-session" } }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
promptAsync: async () => ({ data: {} }),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
const tool = createDelegateTask({ manager: mockManager, client: mockClient })
|
||||
|
||||
// when
|
||||
// then
|
||||
await expect(tool.execute(
|
||||
{
|
||||
description: "Missing required args",
|
||||
prompt: "Do something",
|
||||
load_skills: [],
|
||||
},
|
||||
{ sessionID: "parent-session", messageID: "parent-message", agent: "sisyphus", abort: new AbortController().signal }
|
||||
)).rejects.toThrow("Invalid arguments: 'run_in_background' parameter is REQUIRED")
|
||||
})
|
||||
|
||||
test("#given explicit run_in_background=false #when executing #then sync execution succeeds", async () => {
|
||||
// given
|
||||
const { createDelegateTask } = require("./tools")
|
||||
let promptCalled = false
|
||||
const mockManager = { launch: async () => ({}) }
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [{ name: "oracle", mode: "subagent", model: { providerID: "anthropic", modelID: "claude-opus-4-6" } }] }) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "ses_explicit_false" } }),
|
||||
prompt: async () => {
|
||||
promptCalled = true
|
||||
return { data: {} }
|
||||
},
|
||||
promptAsync: async () => {
|
||||
promptCalled = true
|
||||
return { data: {} }
|
||||
},
|
||||
messages: async () => ({ data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Done" }] }] }),
|
||||
status: async () => ({ data: { ses_explicit_false: { type: "idle" } } }),
|
||||
},
|
||||
}
|
||||
const tool = createDelegateTask({ manager: mockManager, client: mockClient })
|
||||
|
||||
// when
|
||||
const result = await tool.execute(
|
||||
{
|
||||
description: "Explicit false",
|
||||
prompt: "Run sync",
|
||||
subagent_type: "oracle",
|
||||
run_in_background: false,
|
||||
load_skills: [],
|
||||
},
|
||||
{ sessionID: "parent-session", messageID: "parent-message", agent: "sisyphus", abort: new AbortController().signal }
|
||||
)
|
||||
|
||||
// then
|
||||
expect(promptCalled).toBe(true)
|
||||
expect(result).toContain("Done")
|
||||
}, { timeout: 10000 })
|
||||
|
||||
test("#given explicit run_in_background=true #when executing #then background execution succeeds", async () => {
|
||||
// given
|
||||
const { createDelegateTask } = require("./tools")
|
||||
let launchCalled = false
|
||||
const mockManager = {
|
||||
launch: async () => {
|
||||
launchCalled = true
|
||||
return {
|
||||
id: "bg_explicit_true",
|
||||
sessionID: "ses_bg_explicit_true",
|
||||
description: "Explicit true",
|
||||
agent: "Sisyphus-Junior",
|
||||
status: "running",
|
||||
}
|
||||
},
|
||||
}
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
model: { list: async () => [] },
|
||||
session: {
|
||||
create: async () => ({ data: { id: "ses_bg_explicit_true" } }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
promptAsync: async () => ({ data: {} }),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
const tool = createDelegateTask({ manager: mockManager, client: mockClient })
|
||||
|
||||
// when
|
||||
const result = await tool.execute(
|
||||
{
|
||||
description: "Explicit true",
|
||||
prompt: "Run background",
|
||||
category: "quick",
|
||||
run_in_background: true,
|
||||
load_skills: [],
|
||||
},
|
||||
{ sessionID: "parent-session", messageID: "parent-message", agent: "sisyphus", abort: new AbortController().signal }
|
||||
)
|
||||
|
||||
// then
|
||||
expect(launchCalled).toBe(true)
|
||||
expect(result).toContain("Background task launched")
|
||||
}, { timeout: 10000 })
|
||||
})
|
||||
|
||||
describe("session_id with background parameter", () => {
|
||||
test("session_id with background=false should wait for result and return content", async () => {
|
||||
// Note: This test needs extended timeout because the implementation has MIN_STABILITY_TIME_MS = 5000
|
||||
|
||||
@@ -83,7 +83,7 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
|
||||
Available categories:
|
||||
${categoryList}
|
||||
- subagent_type: Use specific agent directly (explore, librarian, oracle, metis, momus)
|
||||
- run_in_background: true=async (returns task_id), false=sync (waits). Default: false. Use background=true ONLY for parallel exploration with 5+ independent queries.
|
||||
- run_in_background: REQUIRED. true=async (returns task_id), false=sync (waits). Use background=true ONLY for parallel exploration with 5+ independent queries.
|
||||
- session_id: Existing Task session to continue (from previous task output). Continues agent with FULL CONTEXT PRESERVED - saves tokens, maintains continuity.
|
||||
- command: The command that triggered this task (optional, for slash command tracking).
|
||||
|
||||
@@ -100,7 +100,7 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
|
||||
load_skills: tool.schema.array(tool.schema.string()).describe("Skill names to inject. REQUIRED - pass [] if no skills needed."),
|
||||
description: tool.schema.string().describe("Short task description (3-5 words)"),
|
||||
prompt: tool.schema.string().describe("Full detailed prompt for the agent"),
|
||||
run_in_background: tool.schema.boolean().describe("true=async (returns task_id), false=sync (waits). Default: false"),
|
||||
run_in_background: tool.schema.boolean().describe("REQUIRED. true=async (returns task_id), false=sync (waits). Use false for task delegation, true ONLY for parallel exploration."),
|
||||
category: tool.schema.string().optional().describe(`REQUIRED if subagent_type not provided. Do NOT provide both category and subagent_type.`),
|
||||
subagent_type: tool.schema.string().optional().describe("REQUIRED if category not provided. Do NOT provide both category and subagent_type."),
|
||||
session_id: tool.schema.string().optional().describe("Existing Task session to continue"),
|
||||
@@ -123,11 +123,7 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
|
||||
})
|
||||
|
||||
if (args.run_in_background === undefined) {
|
||||
if (args.category || args.subagent_type || args.session_id) {
|
||||
args.run_in_background = false
|
||||
} else {
|
||||
throw new Error(`Invalid arguments: 'run_in_background' parameter is REQUIRED. Use run_in_background=false for task delegation, run_in_background=true only for parallel exploration.`)
|
||||
}
|
||||
throw new Error(`Invalid arguments: 'run_in_background' parameter is REQUIRED. Specify run_in_background=false for task delegation, or run_in_background=true for parallel exploration.`)
|
||||
}
|
||||
if (typeof args.load_skills === "string") {
|
||||
try {
|
||||
|
||||
@@ -7,63 +7,89 @@ WORKFLOW:
|
||||
4. If same file needs another call, re-read first.
|
||||
5. Use anchors as "LINE#ID" only (never include trailing "|content").
|
||||
|
||||
VALIDATION:
|
||||
Payload shape: { "filePath": string, "edits": [...], "delete"?: boolean, "rename"?: string }
|
||||
Each edit must be one of: replace, append, prepend
|
||||
Edit shape: { "op": "replace"|"append"|"prepend", "pos"?: "LINE#ID", "end"?: "LINE#ID", "lines": string|string[]|null }
|
||||
lines must contain plain replacement text only (no LINE#ID prefixes, no diff + markers)
|
||||
CRITICAL: all operations validate against the same pre-edit file snapshot and apply bottom-up. Refs/tags are interpreted against the last-read version of the file.
|
||||
<must>
|
||||
- SNAPSHOT: All edits in one call reference the ORIGINAL file state. Do NOT adjust line numbers for prior edits in the same call — the system applies them bottom-up automatically.
|
||||
- replace removes lines pos..end (inclusive) and inserts lines in their place. Lines BEFORE pos and AFTER end are UNTOUCHED — do NOT include them in lines. If you do, they will appear twice.
|
||||
- lines must contain ONLY the content that belongs inside the consumed range. Content after end survives unchanged.
|
||||
- Tags MUST be copied exactly from read output or >>> mismatch output. NEVER guess tags.
|
||||
- Batch = multiple operations in edits[], NOT one big replace covering everything. Each operation targets the smallest possible change.
|
||||
- lines must contain plain replacement text only (no LINE#ID prefixes, no diff + markers).
|
||||
</must>
|
||||
|
||||
LINE#ID FORMAT (CRITICAL):
|
||||
Each line reference must be in "{line_number}#{hash_id}" format where:
|
||||
{line_number}: 1-based line number
|
||||
{hash_id}: Two CID letters from the set ZPMQVRWSNKTXJBYH
|
||||
<operations>
|
||||
LINE#ID FORMAT:
|
||||
Each line reference must be in "{line_number}#{hash_id}" format where:
|
||||
{line_number}: 1-based line number
|
||||
{hash_id}: Two CID letters from the set ZPMQVRWSNKTXJBYH
|
||||
|
||||
FILE MODES:
|
||||
delete=true deletes file and requires edits=[] with no rename
|
||||
rename moves final content to a new path and removes old path
|
||||
OPERATION CHOICE:
|
||||
replace with pos only -> replace one line at pos
|
||||
replace with pos+end -> replace range pos..end inclusive as a block (ranges MUST NOT overlap across edits)
|
||||
append with pos/end anchor -> insert after that anchor
|
||||
prepend with pos/end anchor -> insert before that anchor
|
||||
append/prepend without anchors -> EOF/BOF insertion (also creates missing files)
|
||||
|
||||
CONTENT FORMAT:
|
||||
lines can be a string (single line) or string[] (multi-line, preferred).
|
||||
If you pass a multi-line string, it is split by real newline characters.
|
||||
Literal "\\n" is preserved as text.
|
||||
lines: null or lines: [] with replace -> delete those lines.
|
||||
|
||||
FILE CREATION:
|
||||
append without anchors adds content at EOF. If file does not exist, creates it.
|
||||
prepend without anchors adds content at BOF. If file does not exist, creates it.
|
||||
CRITICAL: only unanchored append/prepend can create a missing file.
|
||||
FILE MODES:
|
||||
delete=true deletes file and requires edits=[] with no rename
|
||||
rename moves final content to a new path and removes old path
|
||||
|
||||
OPERATION CHOICE:
|
||||
replace with pos only -> replace one line at pos
|
||||
replace with pos+end -> replace ENTIRE range pos..end as a block (ranges MUST NOT overlap across edits)
|
||||
append with pos/end anchor -> insert after that anchor
|
||||
prepend with pos/end anchor -> insert before that anchor
|
||||
append/prepend without anchors -> EOF/BOF insertion
|
||||
RULES:
|
||||
1. Minimize scope: one logical mutation site per operation.
|
||||
2. Preserve formatting: keep indentation, punctuation, line breaks, trailing commas, brace style.
|
||||
3. Prefer insertion over neighbor rewrites: anchor to structural boundaries (}, ], },), not interior property lines.
|
||||
4. No no-ops: replacement content must differ from current content.
|
||||
5. Touch only requested code: avoid incidental edits.
|
||||
6. Use exact current tokens: NEVER rewrite approximately.
|
||||
7. For swaps/moves: prefer one range operation over multiple single-line operations.
|
||||
8. Anchor to structural lines (function/class/brace), NEVER blank lines.
|
||||
9. Re-read after each successful edit call before issuing another on the same file.
|
||||
</operations>
|
||||
|
||||
RULES (CRITICAL):
|
||||
1. Minimize scope: one logical mutation site per operation.
|
||||
2. Preserve formatting: keep indentation, punctuation, line breaks, trailing commas, brace style.
|
||||
3. Prefer insertion over neighbor rewrites: anchor to structural boundaries (}, ], },), not interior property lines.
|
||||
4. No no-ops: replacement content must differ from current content.
|
||||
5. Touch only requested code: avoid incidental edits.
|
||||
6. Use exact current tokens: NEVER rewrite approximately.
|
||||
7. For swaps/moves: prefer one range operation over multiple single-line operations.
|
||||
8. Output tool calls only; no prose or commentary between them.
|
||||
<examples>
|
||||
Given this file content after read:
|
||||
10#VK|function hello() {
|
||||
11#XJ| console.log("hi");
|
||||
12#MB| console.log("bye");
|
||||
13#QR|}
|
||||
14#TN|
|
||||
15#WS|function world() {
|
||||
|
||||
TAG CHOICE (ALWAYS):
|
||||
- Copy tags exactly from read output or >>> mismatch output.
|
||||
- NEVER guess tags.
|
||||
- Anchor to structural lines (function/class/brace), NEVER blank lines.
|
||||
- Anti-pattern warning: blank/whitespace anchors are fragile.
|
||||
- Re-read after each successful edit call before issuing another on the same file.
|
||||
Single-line replace (change line 11):
|
||||
{ op: "replace", pos: "11#XJ", lines: [" console.log(\\"hello\\");"] }
|
||||
Result: line 11 replaced. Lines 10, 12-15 unchanged.
|
||||
|
||||
AUTOCORRECT (built-in - you do NOT need to handle these):
|
||||
Merged lines are auto-expanded back to original line count.
|
||||
Indentation is auto-restored from original lines.
|
||||
BOM and CRLF line endings are preserved automatically.
|
||||
Hashline prefixes and diff markers in text are auto-stripped.
|
||||
Range replace (rewrite function body, lines 11-12):
|
||||
{ op: "replace", pos: "11#XJ", end: "12#MB", lines: [" return \\"hello world\\";"] }
|
||||
Result: lines 11-12 removed, replaced by 1 new line. Lines 10, 13-15 unchanged.
|
||||
|
||||
Delete a line:
|
||||
{ op: "replace", pos: "12#MB", lines: null }
|
||||
Result: line 12 removed. Lines 10-11, 13-15 unchanged.
|
||||
|
||||
Insert after line 13 (between functions):
|
||||
{ op: "append", pos: "13#QR", lines: ["", "function added() {", " return true;", "}"] }
|
||||
Result: 4 new lines inserted after line 13. All existing lines unchanged.
|
||||
|
||||
BAD — lines extend past end (DUPLICATES line 13):
|
||||
{ op: "replace", pos: "11#XJ", end: "12#MB", lines: [" return \\"hi\\";", "}"] }
|
||||
Line 13 is "}" which already exists after end. Including "}" in lines duplicates it.
|
||||
CORRECT: { op: "replace", pos: "11#XJ", end: "12#MB", lines: [" return \\"hi\\";"] }
|
||||
</examples>
|
||||
|
||||
<auto>
|
||||
Built-in autocorrect (you do NOT need to handle these):
|
||||
Merged lines are auto-expanded back to original line count.
|
||||
Indentation is auto-restored from original lines.
|
||||
BOM and CRLF line endings are preserved automatically.
|
||||
Hashline prefixes and diff markers in text are auto-stripped.
|
||||
Boundary echo lines (duplicating adjacent surviving lines) are auto-stripped.
|
||||
</auto>
|
||||
|
||||
RECOVERY (when >>> mismatch error appears):
|
||||
Copy the updated LINE#ID tags shown in the error output directly.
|
||||
Re-read only if the needed tags are missing from the error snippet.
|
||||
ALWAYS batch all edits for one file in a single call.`
|
||||
Copy the updated LINE#ID tags shown in the error output directly.
|
||||
Re-read only if the needed tags are missing from the error snippet.`
|
||||
|
||||
@@ -30,7 +30,7 @@ export function createHashlineEditTool(): ToolDefinition {
|
||||
pos: tool.schema.string().optional().describe("Primary anchor in LINE#ID format"),
|
||||
end: tool.schema.string().optional().describe("Range end anchor in LINE#ID format"),
|
||||
lines: tool.schema
|
||||
.union([tool.schema.string(), tool.schema.null()])
|
||||
.union([tool.schema.array(tool.schema.string()), tool.schema.string(), tool.schema.null()])
|
||||
.describe("Replacement or inserted lines as newline-delimited string. null deletes with replace"),
|
||||
})
|
||||
)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export const MULTIMODAL_LOOKER_AGENT = "multimodal-looker" as const
|
||||
|
||||
export const LOOK_AT_DESCRIPTION = `Analyze media files (PDFs, images, diagrams) that require interpretation beyond raw text. Extracts specific information or summaries from documents, describes visual content. Use when you need analyzed/extracted data rather than literal file contents.`
|
||||
export const LOOK_AT_DESCRIPTION = `Extract basic information from media files (PDFs, images, diagrams) when a quick summary suffices over precise reading. Good for simple text-based content extraction without using the Read tool. NEVER use for visual precision, aesthetic evaluation, or exact accuracy — use Read tool instead for those cases.`
|
||||
|
||||
@@ -525,3 +525,59 @@ describe("skill tool - dynamic discovery", () => {
|
||||
expect(result).not.toContain("SHOULD_BE_OVERRIDDEN")
|
||||
})
|
||||
})
|
||||
describe("skill tool - dynamic description cache invalidation", () => {
|
||||
it("rebuilds description after execute() discovers new skills", async () => {
|
||||
// given: tool created with initial skills (no pre-provided skills)
|
||||
// This triggers lazy description building
|
||||
const tool = createSkillTool({})
|
||||
|
||||
// Get initial description - it will build from empty or disk skills
|
||||
const initialDescription = tool.description
|
||||
|
||||
// when: execute() is called, which clears cache AND gets fresh skills
|
||||
// Note: In real scenario, execute() would discover new skills from disk
|
||||
// For testing, we verify the mechanism: execute() should invalidate cachedDescription
|
||||
|
||||
// Execute any skill to trigger the cache clear + getSkills flow
|
||||
// Using a non-existent skill name to trigger the error path which still goes through getSkills()
|
||||
try {
|
||||
await tool.execute({ name: "nonexistent-skill-12345" }, mockContext)
|
||||
} catch (e) {
|
||||
// Expected to fail - skill doesn't exist
|
||||
}
|
||||
|
||||
// then: cachedDescription should be invalidated, so next description access should rebuild
|
||||
// We verify by checking that the description getter triggers a rebuild
|
||||
// Since we can't easily mock getAllSkills in this test, we verify the cache invalidation mechanism
|
||||
|
||||
// The key assertion: after execute(), the description should be rebuildable
|
||||
// If cachedDescription wasn't invalidated, it would still return old value
|
||||
// We verify by checking that the tool still has valid description structure
|
||||
expect(tool.description).toBeDefined()
|
||||
expect(typeof tool.description).toBe("string")
|
||||
})
|
||||
|
||||
it("description reflects fresh skills after execute() clears cache", async () => {
|
||||
// given: tool created without pre-provided skills (will use disk discovery)
|
||||
const tool = createSkillTool({})
|
||||
|
||||
// when: execute() is called with a skill that exists on disk (via mock)
|
||||
// This simulates the real scenario: execute() discovers skills, cache should be invalidated
|
||||
|
||||
// Execute to trigger the cache invalidation path
|
||||
try {
|
||||
// This will call getSkills() which clears cache
|
||||
await tool.execute({ name: "nonexistent" }, mockContext)
|
||||
} catch (e) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
// then: description should still work and not be stale
|
||||
// The bug would cause it to return old cached value forever
|
||||
const desc = tool.description
|
||||
|
||||
// Verify description is a valid string (not stale/old)
|
||||
expect(desc).toContain("skill")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -235,6 +235,7 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
|
||||
},
|
||||
async execute(args: SkillArgs, ctx?: { agent?: string }) {
|
||||
const skills = await getSkills()
|
||||
cachedDescription = null
|
||||
const commands = getCommands()
|
||||
|
||||
const requestedName = args.name.replace(/^\//, "")
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"": {
|
||||
"name": "hashline-edit-benchmark",
|
||||
"dependencies": {
|
||||
"@friendliai/ai-provider": "^1.0.9",
|
||||
"@ai-sdk/openai-compatible": "^2.0.35",
|
||||
"ai": "^6.0.94",
|
||||
"zod": "^4.1.0",
|
||||
},
|
||||
@@ -14,13 +14,11 @@
|
||||
"packages": {
|
||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.55", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7xMeTJnCjwRwXKVCiv4Ly4qzWvDuW3+W1WIV0X1EFu6W83d4mEhV9bFArto10MeTw40ewuDjrbrZd21mXKohkw=="],
|
||||
|
||||
"@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.30", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iTjumHf1/u4NhjXYFn/aONM2GId3/o7J1Lp5ql8FCbgIMyRwrmanR5xy1S3aaVkfTscuDvLTzWiy1mAbGzK3nQ=="],
|
||||
"@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-g3wA57IAQFb+3j4YuFndgkUdXyRETZVvbfAWM+UX7bZSxA3xjes0v3XKgIdKdekPtDGsh4ZX2byHD0gJIMPfiA=="],
|
||||
|
||||
"@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
|
||||
|
||||
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.15", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w=="],
|
||||
|
||||
"@friendliai/ai-provider": ["@friendliai/ai-provider@1.1.4", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.30", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.12" } }, "sha512-9TU4B1QFqPhbkONjI5afCF7Ox4jOqtGg1xw8mA9QHZdtlEbZxU+mBNvMPlI5pU5kPoN6s7wkXmFmxpID+own1A=="],
|
||||
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.19", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg=="],
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
@@ -35,5 +33,9 @@
|
||||
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
|
||||
|
||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.15", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w=="],
|
||||
|
||||
"ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.15", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w=="],
|
||||
}
|
||||
}
|
||||
@@ -3,16 +3,17 @@ import { readFile, writeFile, mkdir } from "node:fs/promises"
|
||||
import { join, dirname } from "node:path"
|
||||
import { stepCountIs, streamText, type CoreMessage } from "ai"
|
||||
import { tool } from "ai"
|
||||
import { createFriendli } from "@friendliai/ai-provider"
|
||||
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
|
||||
import { z } from "zod"
|
||||
import { formatHashLines } from "../src/tools/hashline-edit/hash-computation"
|
||||
import { normalizeHashlineEdits } from "../src/tools/hashline-edit/normalize-edits"
|
||||
import { applyHashlineEditsWithReport } from "../src/tools/hashline-edit/edit-operations"
|
||||
import { canonicalizeFileText, restoreFileText } from "../src/tools/hashline-edit/file-text-canonicalization"
|
||||
import { formatHashLines } from "../../src/tools/hashline-edit/hash-computation"
|
||||
import { normalizeHashlineEdits } from "../../src/tools/hashline-edit/normalize-edits"
|
||||
import { applyHashlineEditsWithReport } from "../../src/tools/hashline-edit/edit-operations"
|
||||
import { canonicalizeFileText, restoreFileText } from "../../src/tools/hashline-edit/file-text-canonicalization"
|
||||
import { HASHLINE_EDIT_DESCRIPTION } from "../../src/tools/hashline-edit/tool-description"
|
||||
|
||||
const DEFAULT_MODEL = "MiniMaxAI/MiniMax-M2.5"
|
||||
const DEFAULT_MODEL = "minimax-m2.5-free"
|
||||
const MAX_STEPS = 50
|
||||
const sessionId = `bench-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
const sessionId = `hashline-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
|
||||
const emit = (event: Record<string, unknown>) =>
|
||||
console.log(JSON.stringify({ sessionId, timestamp: new Date().toISOString(), ...event }))
|
||||
@@ -33,7 +34,7 @@ function parseArgs(): { prompt: string; modelId: string } {
|
||||
// --no-translate, --think consumed silently
|
||||
}
|
||||
if (!prompt) {
|
||||
console.error("Usage: bun run benchmarks/headless.ts -p <prompt> [-m <model>]")
|
||||
console.error("Usage: bun run tests/hashline/headless.ts -p <prompt> [-m <model>]")
|
||||
process.exit(1)
|
||||
}
|
||||
return { prompt, modelId }
|
||||
@@ -57,7 +58,7 @@ const readFileTool = tool({
|
||||
})
|
||||
|
||||
const editFileTool = tool({
|
||||
description: "Edit a file using hashline anchors (LINE#ID format)",
|
||||
description: HASHLINE_EDIT_DESCRIPTION,
|
||||
inputSchema: z.object({
|
||||
path: z.string(),
|
||||
edits: z.array(
|
||||
@@ -116,8 +117,12 @@ const editFileTool = tool({
|
||||
async function run() {
|
||||
const { prompt, modelId } = parseArgs()
|
||||
|
||||
const friendli = createFriendli({ apiKey: process.env.FRIENDLI_TOKEN! })
|
||||
const model = friendli(modelId)
|
||||
const provider = createOpenAICompatible({
|
||||
name: "hashline-test",
|
||||
baseURL: process.env.HASHLINE_TEST_BASE_URL ?? "https://quotio.mengmota.com/v1",
|
||||
apiKey: process.env.HASHLINE_TEST_API_KEY ?? "quotio-local-60A613FE-DB74-40FF-923E-A14151951E5D",
|
||||
})
|
||||
const model = provider.chatModel(modelId)
|
||||
const tools = { read_file: readFileTool, edit_file: editFileTool }
|
||||
|
||||
emit({ type: "user", content: prompt })
|
||||
@@ -125,7 +130,8 @@ async function run() {
|
||||
const messages: CoreMessage[] = [{ role: "user", content: prompt }]
|
||||
const system =
|
||||
"You are a code editing assistant. Use read_file to read files and edit_file to edit them. " +
|
||||
"Always read a file before editing it to get fresh LINE#ID anchors."
|
||||
"Always read a file before editing it to get fresh LINE#ID anchors.\n\n" +
|
||||
"edit_file tool description:\n" + HASHLINE_EDIT_DESCRIPTION
|
||||
|
||||
for (let step = 0; step < MAX_STEPS; step++) {
|
||||
const stream = streamText({
|
||||
@@ -161,6 +167,7 @@ async function run() {
|
||||
...(isError ? { error: output } : {}),
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,3 +198,4 @@ run()
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2)
|
||||
console.error(`[headless] Completed in ${elapsed}s`)
|
||||
})
|
||||
|
||||
18
tests/hashline/package.json
Normal file
18
tests/hashline/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "hashline-edit-tests",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Hashline edit tool integration tests using Vercel AI SDK",
|
||||
"scripts": {
|
||||
"test:basic": "bun run test-edit-ops.ts",
|
||||
"test:edge": "bun run test-edge-cases.ts",
|
||||
"test:multi": "bun run test-multi-model.ts",
|
||||
"test:all": "bun run test:basic && bun run test:edge"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai-compatible": "^2.0.35",
|
||||
"ai": "^6.0.94",
|
||||
"zod": "^4.1.0"
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,7 @@ import { resolve } from "node:path";
|
||||
|
||||
// ── Models ────────────────────────────────────────────────────
|
||||
const MODELS = [
|
||||
{ id: "MiniMaxAI/MiniMax-M2.5", short: "M2.5" },
|
||||
// { id: "MiniMaxAI/MiniMax-M2.1", short: "M2.1" }, // masked: slow + timeout-prone
|
||||
// { id: "zai-org/GLM-5", short: "GLM-5" }, // masked: API 503
|
||||
{ id: "zai-org/GLM-4.7", short: "GLM-4.7" },
|
||||
{ id: "minimax-m2.5-free", short: "M2.5-Free" },
|
||||
];
|
||||
|
||||
// ── CLI args ──────────────────────────────────────────────────
|
||||
Reference in New Issue
Block a user