Merge pull request #2550 from code-yeongyu/fix/deploy-blockers
fix: resolve all deployment blockers from v3.11.2→HEAD release review
This commit is contained in:
@@ -31,6 +31,9 @@ exports[`generateModelConfig no providers available returns ULTIMATE_FALLBACK fo
|
|||||||
"prometheus": {
|
"prometheus": {
|
||||||
"model": "opencode/glm-4.7-free",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
|
"sisyphus-junior": {
|
||||||
|
"model": "opencode/glm-4.7-free",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
@@ -71,9 +74,6 @@ exports[`generateModelConfig single native provider uses Claude models when only
|
|||||||
"explore": {
|
"explore": {
|
||||||
"model": "anthropic/claude-haiku-4-5",
|
"model": "anthropic/claude-haiku-4-5",
|
||||||
},
|
},
|
||||||
"librarian": {
|
|
||||||
"model": "anthropic/claude-sonnet-4-5",
|
|
||||||
},
|
|
||||||
"metis": {
|
"metis": {
|
||||||
"model": "anthropic/claude-opus-4-6",
|
"model": "anthropic/claude-opus-4-6",
|
||||||
"variant": "max",
|
"variant": "max",
|
||||||
@@ -97,6 +97,9 @@ exports[`generateModelConfig single native provider uses Claude models when only
|
|||||||
"model": "anthropic/claude-opus-4-6",
|
"model": "anthropic/claude-opus-4-6",
|
||||||
"variant": "max",
|
"variant": "max",
|
||||||
},
|
},
|
||||||
|
"sisyphus-junior": {
|
||||||
|
"model": "anthropic/claude-sonnet-4-6",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"quick": {
|
"quick": {
|
||||||
@@ -133,9 +136,6 @@ exports[`generateModelConfig single native provider uses Claude models with isMa
|
|||||||
"explore": {
|
"explore": {
|
||||||
"model": "anthropic/claude-haiku-4-5",
|
"model": "anthropic/claude-haiku-4-5",
|
||||||
},
|
},
|
||||||
"librarian": {
|
|
||||||
"model": "anthropic/claude-sonnet-4-5",
|
|
||||||
},
|
|
||||||
"metis": {
|
"metis": {
|
||||||
"model": "anthropic/claude-opus-4-6",
|
"model": "anthropic/claude-opus-4-6",
|
||||||
"variant": "max",
|
"variant": "max",
|
||||||
@@ -159,6 +159,9 @@ exports[`generateModelConfig single native provider uses Claude models with isMa
|
|||||||
"model": "anthropic/claude-opus-4-6",
|
"model": "anthropic/claude-opus-4-6",
|
||||||
"variant": "max",
|
"variant": "max",
|
||||||
},
|
},
|
||||||
|
"sisyphus-junior": {
|
||||||
|
"model": "anthropic/claude-sonnet-4-6",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"quick": {
|
"quick": {
|
||||||
@@ -191,8 +194,7 @@ exports[`generateModelConfig single native provider uses OpenAI models when only
|
|||||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||||
"agents": {
|
"agents": {
|
||||||
"atlas": {
|
"atlas": {
|
||||||
"model": "openai/gpt-5.4",
|
"model": "opencode/glm-4.7-free",
|
||||||
"variant": "medium",
|
|
||||||
},
|
},
|
||||||
"explore": {
|
"explore": {
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
@@ -207,8 +209,7 @@ exports[`generateModelConfig single native provider uses OpenAI models when only
|
|||||||
"variant": "medium",
|
"variant": "medium",
|
||||||
},
|
},
|
||||||
"metis": {
|
"metis": {
|
||||||
"model": "openai/gpt-5.4",
|
"model": "opencode/glm-4.7-free",
|
||||||
"variant": "high",
|
|
||||||
},
|
},
|
||||||
"momus": {
|
"momus": {
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
@@ -230,6 +231,9 @@ exports[`generateModelConfig single native provider uses OpenAI models when only
|
|||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"variant": "medium",
|
"variant": "medium",
|
||||||
},
|
},
|
||||||
|
"sisyphus-junior": {
|
||||||
|
"model": "opencode/glm-4.7-free",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
@@ -273,8 +277,7 @@ exports[`generateModelConfig single native provider uses OpenAI models with isMa
|
|||||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||||
"agents": {
|
"agents": {
|
||||||
"atlas": {
|
"atlas": {
|
||||||
"model": "openai/gpt-5.4",
|
"model": "opencode/glm-4.7-free",
|
||||||
"variant": "medium",
|
|
||||||
},
|
},
|
||||||
"explore": {
|
"explore": {
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
@@ -289,8 +292,7 @@ exports[`generateModelConfig single native provider uses OpenAI models with isMa
|
|||||||
"variant": "medium",
|
"variant": "medium",
|
||||||
},
|
},
|
||||||
"metis": {
|
"metis": {
|
||||||
"model": "openai/gpt-5.4",
|
"model": "opencode/glm-4.7-free",
|
||||||
"variant": "high",
|
|
||||||
},
|
},
|
||||||
"momus": {
|
"momus": {
|
||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
@@ -312,6 +314,9 @@ exports[`generateModelConfig single native provider uses OpenAI models with isMa
|
|||||||
"model": "openai/gpt-5.4",
|
"model": "openai/gpt-5.4",
|
||||||
"variant": "medium",
|
"variant": "medium",
|
||||||
},
|
},
|
||||||
|
"sisyphus-junior": {
|
||||||
|
"model": "opencode/glm-4.7-free",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
@@ -355,24 +360,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",
|
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||||
"agents": {
|
"agents": {
|
||||||
"atlas": {
|
"atlas": {
|
||||||
"model": "google/gemini-3.1-pro-preview",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"explore": {
|
"explore": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/gpt-5-nano",
|
||||||
},
|
},
|
||||||
"librarian": {
|
|
||||||
"model": "opencode/glm-4.7-free",
|
|
||||||
},
|
|
||||||
"metis": {
|
"metis": {
|
||||||
"model": "google/gemini-3.1-pro-preview",
|
"model": "opencode/glm-4.7-free",
|
||||||
"variant": "high",
|
|
||||||
},
|
},
|
||||||
"momus": {
|
"momus": {
|
||||||
"model": "google/gemini-3.1-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"multimodal-looker": {
|
"multimodal-looker": {
|
||||||
"model": "google/gemini-3-flash-preview",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"oracle": {
|
"oracle": {
|
||||||
"model": "google/gemini-3.1-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
@@ -381,6 +382,9 @@ exports[`generateModelConfig single native provider uses Gemini models when only
|
|||||||
"prometheus": {
|
"prometheus": {
|
||||||
"model": "google/gemini-3.1-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
},
|
},
|
||||||
|
"sisyphus-junior": {
|
||||||
|
"model": "opencode/glm-4.7-free",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
@@ -416,24 +420,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",
|
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||||
"agents": {
|
"agents": {
|
||||||
"atlas": {
|
"atlas": {
|
||||||
"model": "google/gemini-3.1-pro-preview",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"explore": {
|
"explore": {
|
||||||
"model": "opencode/gpt-5-nano",
|
"model": "opencode/gpt-5-nano",
|
||||||
},
|
},
|
||||||
"librarian": {
|
|
||||||
"model": "opencode/glm-4.7-free",
|
|
||||||
},
|
|
||||||
"metis": {
|
"metis": {
|
||||||
"model": "google/gemini-3.1-pro-preview",
|
"model": "opencode/glm-4.7-free",
|
||||||
"variant": "high",
|
|
||||||
},
|
},
|
||||||
"momus": {
|
"momus": {
|
||||||
"model": "google/gemini-3.1-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
"variant": "high",
|
"variant": "high",
|
||||||
},
|
},
|
||||||
"multimodal-looker": {
|
"multimodal-looker": {
|
||||||
"model": "google/gemini-3-flash-preview",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"oracle": {
|
"oracle": {
|
||||||
"model": "google/gemini-3.1-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
@@ -442,6 +442,9 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
|
|||||||
"prometheus": {
|
"prometheus": {
|
||||||
"model": "google/gemini-3.1-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
},
|
},
|
||||||
|
"sisyphus-junior": {
|
||||||
|
"model": "opencode/glm-4.7-free",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
@@ -486,9 +489,6 @@ exports[`generateModelConfig all native providers uses preferred models from fal
|
|||||||
"model": "openai/gpt-5.3-codex",
|
"model": "openai/gpt-5.3-codex",
|
||||||
"variant": "medium",
|
"variant": "medium",
|
||||||
},
|
},
|
||||||
"librarian": {
|
|
||||||
"model": "anthropic/claude-sonnet-4-5",
|
|
||||||
},
|
|
||||||
"metis": {
|
"metis": {
|
||||||
"model": "anthropic/claude-opus-4-6",
|
"model": "anthropic/claude-opus-4-6",
|
||||||
"variant": "max",
|
"variant": "max",
|
||||||
@@ -513,6 +513,9 @@ exports[`generateModelConfig all native providers uses preferred models from fal
|
|||||||
"model": "anthropic/claude-opus-4-6",
|
"model": "anthropic/claude-opus-4-6",
|
||||||
"variant": "max",
|
"variant": "max",
|
||||||
},
|
},
|
||||||
|
"sisyphus-junior": {
|
||||||
|
"model": "anthropic/claude-sonnet-4-6",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
@@ -561,9 +564,6 @@ exports[`generateModelConfig all native providers uses preferred models with isM
|
|||||||
"model": "openai/gpt-5.3-codex",
|
"model": "openai/gpt-5.3-codex",
|
||||||
"variant": "medium",
|
"variant": "medium",
|
||||||
},
|
},
|
||||||
"librarian": {
|
|
||||||
"model": "anthropic/claude-sonnet-4-5",
|
|
||||||
},
|
|
||||||
"metis": {
|
"metis": {
|
||||||
"model": "anthropic/claude-opus-4-6",
|
"model": "anthropic/claude-opus-4-6",
|
||||||
"variant": "max",
|
"variant": "max",
|
||||||
@@ -588,6 +588,9 @@ exports[`generateModelConfig all native providers uses preferred models with isM
|
|||||||
"model": "anthropic/claude-opus-4-6",
|
"model": "anthropic/claude-opus-4-6",
|
||||||
"variant": "max",
|
"variant": "max",
|
||||||
},
|
},
|
||||||
|
"sisyphus-junior": {
|
||||||
|
"model": "anthropic/claude-sonnet-4-6",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
@@ -637,9 +640,6 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on
|
|||||||
"model": "opencode/gpt-5.3-codex",
|
"model": "opencode/gpt-5.3-codex",
|
||||||
"variant": "medium",
|
"variant": "medium",
|
||||||
},
|
},
|
||||||
"librarian": {
|
|
||||||
"model": "opencode/glm-4.7-free",
|
|
||||||
},
|
|
||||||
"metis": {
|
"metis": {
|
||||||
"model": "opencode/claude-opus-4-6",
|
"model": "opencode/claude-opus-4-6",
|
||||||
"variant": "max",
|
"variant": "max",
|
||||||
@@ -664,6 +664,9 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on
|
|||||||
"model": "opencode/claude-opus-4-6",
|
"model": "opencode/claude-opus-4-6",
|
||||||
"variant": "max",
|
"variant": "max",
|
||||||
},
|
},
|
||||||
|
"sisyphus-junior": {
|
||||||
|
"model": "opencode/claude-sonnet-4-6",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
@@ -712,9 +715,6 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is
|
|||||||
"model": "opencode/gpt-5.3-codex",
|
"model": "opencode/gpt-5.3-codex",
|
||||||
"variant": "medium",
|
"variant": "medium",
|
||||||
},
|
},
|
||||||
"librarian": {
|
|
||||||
"model": "opencode/glm-4.7-free",
|
|
||||||
},
|
|
||||||
"metis": {
|
"metis": {
|
||||||
"model": "opencode/claude-opus-4-6",
|
"model": "opencode/claude-opus-4-6",
|
||||||
"variant": "max",
|
"variant": "max",
|
||||||
@@ -739,6 +739,9 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is
|
|||||||
"model": "opencode/claude-opus-4-6",
|
"model": "opencode/claude-opus-4-6",
|
||||||
"variant": "max",
|
"variant": "max",
|
||||||
},
|
},
|
||||||
|
"sisyphus-junior": {
|
||||||
|
"model": "opencode/claude-sonnet-4-6",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
@@ -784,9 +787,6 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
|
|||||||
"explore": {
|
"explore": {
|
||||||
"model": "github-copilot/gpt-5-mini",
|
"model": "github-copilot/gpt-5-mini",
|
||||||
},
|
},
|
||||||
"librarian": {
|
|
||||||
"model": "github-copilot/claude-sonnet-4.5",
|
|
||||||
},
|
|
||||||
"metis": {
|
"metis": {
|
||||||
"model": "github-copilot/claude-opus-4.6",
|
"model": "github-copilot/claude-opus-4.6",
|
||||||
"variant": "max",
|
"variant": "max",
|
||||||
@@ -796,7 +796,7 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
|
|||||||
"variant": "xhigh",
|
"variant": "xhigh",
|
||||||
},
|
},
|
||||||
"multimodal-looker": {
|
"multimodal-looker": {
|
||||||
"model": "github-copilot/gemini-3-flash-preview",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"oracle": {
|
"oracle": {
|
||||||
"model": "github-copilot/gpt-5.4",
|
"model": "github-copilot/gpt-5.4",
|
||||||
@@ -810,6 +810,9 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
|
|||||||
"model": "github-copilot/claude-opus-4.6",
|
"model": "github-copilot/claude-opus-4.6",
|
||||||
"variant": "max",
|
"variant": "max",
|
||||||
},
|
},
|
||||||
|
"sisyphus-junior": {
|
||||||
|
"model": "github-copilot/claude-sonnet-4.6",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
@@ -850,9 +853,6 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
|
|||||||
"explore": {
|
"explore": {
|
||||||
"model": "github-copilot/gpt-5-mini",
|
"model": "github-copilot/gpt-5-mini",
|
||||||
},
|
},
|
||||||
"librarian": {
|
|
||||||
"model": "github-copilot/claude-sonnet-4.5",
|
|
||||||
},
|
|
||||||
"metis": {
|
"metis": {
|
||||||
"model": "github-copilot/claude-opus-4.6",
|
"model": "github-copilot/claude-opus-4.6",
|
||||||
"variant": "max",
|
"variant": "max",
|
||||||
@@ -862,7 +862,7 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
|
|||||||
"variant": "xhigh",
|
"variant": "xhigh",
|
||||||
},
|
},
|
||||||
"multimodal-looker": {
|
"multimodal-looker": {
|
||||||
"model": "github-copilot/gemini-3-flash-preview",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"oracle": {
|
"oracle": {
|
||||||
"model": "github-copilot/gpt-5.4",
|
"model": "github-copilot/gpt-5.4",
|
||||||
@@ -876,6 +876,9 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
|
|||||||
"model": "github-copilot/claude-opus-4.6",
|
"model": "github-copilot/claude-opus-4.6",
|
||||||
"variant": "max",
|
"variant": "max",
|
||||||
},
|
},
|
||||||
|
"sisyphus-junior": {
|
||||||
|
"model": "github-copilot/claude-sonnet-4.6",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
@@ -938,6 +941,9 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian whe
|
|||||||
"sisyphus": {
|
"sisyphus": {
|
||||||
"model": "zai-coding-plan/glm-5",
|
"model": "zai-coding-plan/glm-5",
|
||||||
},
|
},
|
||||||
|
"sisyphus-junior": {
|
||||||
|
"model": "opencode/glm-4.7-free",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"quick": {
|
"quick": {
|
||||||
@@ -993,6 +999,9 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian wit
|
|||||||
"sisyphus": {
|
"sisyphus": {
|
||||||
"model": "zai-coding-plan/glm-5",
|
"model": "zai-coding-plan/glm-5",
|
||||||
},
|
},
|
||||||
|
"sisyphus-junior": {
|
||||||
|
"model": "opencode/glm-4.7-free",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"quick": {
|
"quick": {
|
||||||
@@ -1031,9 +1040,6 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen
|
|||||||
"model": "opencode/gpt-5.3-codex",
|
"model": "opencode/gpt-5.3-codex",
|
||||||
"variant": "medium",
|
"variant": "medium",
|
||||||
},
|
},
|
||||||
"librarian": {
|
|
||||||
"model": "opencode/glm-4.7-free",
|
|
||||||
},
|
|
||||||
"metis": {
|
"metis": {
|
||||||
"model": "anthropic/claude-opus-4-6",
|
"model": "anthropic/claude-opus-4-6",
|
||||||
"variant": "max",
|
"variant": "max",
|
||||||
@@ -1058,6 +1064,9 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen
|
|||||||
"model": "anthropic/claude-opus-4-6",
|
"model": "anthropic/claude-opus-4-6",
|
||||||
"variant": "max",
|
"variant": "max",
|
||||||
},
|
},
|
||||||
|
"sisyphus-junior": {
|
||||||
|
"model": "anthropic/claude-sonnet-4-6",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
@@ -1106,9 +1115,6 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb
|
|||||||
"model": "openai/gpt-5.3-codex",
|
"model": "openai/gpt-5.3-codex",
|
||||||
"variant": "medium",
|
"variant": "medium",
|
||||||
},
|
},
|
||||||
"librarian": {
|
|
||||||
"model": "github-copilot/claude-sonnet-4.5",
|
|
||||||
},
|
|
||||||
"metis": {
|
"metis": {
|
||||||
"model": "github-copilot/claude-opus-4.6",
|
"model": "github-copilot/claude-opus-4.6",
|
||||||
"variant": "max",
|
"variant": "max",
|
||||||
@@ -1133,6 +1139,9 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb
|
|||||||
"model": "github-copilot/claude-opus-4.6",
|
"model": "github-copilot/claude-opus-4.6",
|
||||||
"variant": "max",
|
"variant": "max",
|
||||||
},
|
},
|
||||||
|
"sisyphus-junior": {
|
||||||
|
"model": "github-copilot/claude-sonnet-4.6",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
@@ -1203,6 +1212,9 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + ZAI combinat
|
|||||||
"model": "anthropic/claude-opus-4-6",
|
"model": "anthropic/claude-opus-4-6",
|
||||||
"variant": "max",
|
"variant": "max",
|
||||||
},
|
},
|
||||||
|
"sisyphus-junior": {
|
||||||
|
"model": "anthropic/claude-sonnet-4-6",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"quick": {
|
"quick": {
|
||||||
@@ -1238,9 +1250,6 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
|
|||||||
"explore": {
|
"explore": {
|
||||||
"model": "anthropic/claude-haiku-4-5",
|
"model": "anthropic/claude-haiku-4-5",
|
||||||
},
|
},
|
||||||
"librarian": {
|
|
||||||
"model": "anthropic/claude-sonnet-4-5",
|
|
||||||
},
|
|
||||||
"metis": {
|
"metis": {
|
||||||
"model": "anthropic/claude-opus-4-6",
|
"model": "anthropic/claude-opus-4-6",
|
||||||
"variant": "max",
|
"variant": "max",
|
||||||
@@ -1250,7 +1259,7 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
|
|||||||
"variant": "max",
|
"variant": "max",
|
||||||
},
|
},
|
||||||
"multimodal-looker": {
|
"multimodal-looker": {
|
||||||
"model": "google/gemini-3-flash-preview",
|
"model": "opencode/glm-4.7-free",
|
||||||
},
|
},
|
||||||
"oracle": {
|
"oracle": {
|
||||||
"model": "google/gemini-3.1-pro-preview",
|
"model": "google/gemini-3.1-pro-preview",
|
||||||
@@ -1264,6 +1273,9 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
|
|||||||
"model": "anthropic/claude-opus-4-6",
|
"model": "anthropic/claude-opus-4-6",
|
||||||
"variant": "max",
|
"variant": "max",
|
||||||
},
|
},
|
||||||
|
"sisyphus-junior": {
|
||||||
|
"model": "anthropic/claude-sonnet-4-6",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
@@ -1335,6 +1347,9 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
|
|||||||
"model": "github-copilot/claude-opus-4.6",
|
"model": "github-copilot/claude-opus-4.6",
|
||||||
"variant": "max",
|
"variant": "max",
|
||||||
},
|
},
|
||||||
|
"sisyphus-junior": {
|
||||||
|
"model": "github-copilot/claude-sonnet-4.6",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
@@ -1410,6 +1425,9 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
|
|||||||
"model": "anthropic/claude-opus-4-6",
|
"model": "anthropic/claude-opus-4-6",
|
||||||
"variant": "max",
|
"variant": "max",
|
||||||
},
|
},
|
||||||
|
"sisyphus-junior": {
|
||||||
|
"model": "anthropic/claude-sonnet-4-6",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
@@ -1485,6 +1503,9 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
|
|||||||
"model": "anthropic/claude-opus-4-6",
|
"model": "anthropic/claude-opus-4-6",
|
||||||
"variant": "max",
|
"variant": "max",
|
||||||
},
|
},
|
||||||
|
"sisyphus-junior": {
|
||||||
|
"model": "anthropic/claude-sonnet-4-6",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"artistry": {
|
"artistry": {
|
||||||
|
|||||||
@@ -495,15 +495,15 @@ describe("generateModelConfig", () => {
|
|||||||
expect(result.agents?.librarian?.model).toBe("zai-coding-plan/glm-4.7")
|
expect(result.agents?.librarian?.model).toBe("zai-coding-plan/glm-4.7")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("librarian falls back to generic chain result when no librarian provider matches", () => {
|
test("librarian is omitted when no librarian provider matches", () => {
|
||||||
// #given only Claude is available (no ZAI)
|
// #given only Claude is available (no opencode-go or ZAI)
|
||||||
const config = createConfig({ hasClaude: true })
|
const config = createConfig({ hasClaude: true })
|
||||||
|
|
||||||
// #when generateModelConfig is called
|
// #when generateModelConfig is called
|
||||||
const result = generateModelConfig(config)
|
const result = generateModelConfig(config)
|
||||||
|
|
||||||
// #then librarian should use generic chain result when chain providers are unavailable
|
// #then librarian should be omitted when its dedicated providers are unavailable
|
||||||
expect(result.agents?.librarian?.model).toBe("anthropic/claude-sonnet-4-5")
|
expect(result.agents?.librarian).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
|
||||||
|
import { _resetForTesting, subagentSessions } from "../claude-code-session-state"
|
||||||
|
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||||
|
import { BackgroundManager } from "./manager"
|
||||||
|
import type { BackgroundTask } from "./types"
|
||||||
|
|
||||||
|
function createTask(overrides: Partial<BackgroundTask> & { id: string; sessionID: string }): BackgroundTask {
|
||||||
|
return {
|
||||||
|
parentSessionID: "parent-session",
|
||||||
|
parentMessageID: "parent-message",
|
||||||
|
description: "test task",
|
||||||
|
prompt: "test prompt",
|
||||||
|
agent: "explore",
|
||||||
|
status: "running",
|
||||||
|
startedAt: new Date(),
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBackgroundManager(): BackgroundManager {
|
||||||
|
return new BackgroundManager({
|
||||||
|
client: {
|
||||||
|
session: {
|
||||||
|
abort: async () => ({}),
|
||||||
|
prompt: async () => ({}),
|
||||||
|
promptAsync: async () => ({}),
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
project: {} as never,
|
||||||
|
directory: tmpdir(),
|
||||||
|
worktree: tmpdir(),
|
||||||
|
serverUrl: new URL("https://example.com"),
|
||||||
|
$: {} as never,
|
||||||
|
} as never)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("BackgroundManager shutdown global cleanup", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// given
|
||||||
|
_resetForTesting()
|
||||||
|
SessionCategoryRegistry.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// given
|
||||||
|
_resetForTesting()
|
||||||
|
SessionCategoryRegistry.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("removes tracked session IDs from subagentSessions and SessionCategoryRegistry on shutdown", async () => {
|
||||||
|
// given
|
||||||
|
const runningSessionID = "ses-running-shutdown-cleanup"
|
||||||
|
const completedSessionID = "ses-completed-shutdown-cleanup"
|
||||||
|
const unrelatedSessionID = "ses-unrelated-shutdown-cleanup"
|
||||||
|
const manager = createBackgroundManager()
|
||||||
|
const tasks = new Map<string, BackgroundTask>([
|
||||||
|
[
|
||||||
|
"task-running-shutdown-cleanup",
|
||||||
|
createTask({
|
||||||
|
id: "task-running-shutdown-cleanup",
|
||||||
|
sessionID: runningSessionID,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"task-completed-shutdown-cleanup",
|
||||||
|
createTask({
|
||||||
|
id: "task-completed-shutdown-cleanup",
|
||||||
|
sessionID: completedSessionID,
|
||||||
|
status: "completed",
|
||||||
|
completedAt: new Date(),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
])
|
||||||
|
|
||||||
|
Object.assign(manager, { tasks })
|
||||||
|
|
||||||
|
subagentSessions.add(runningSessionID)
|
||||||
|
subagentSessions.add(completedSessionID)
|
||||||
|
subagentSessions.add(unrelatedSessionID)
|
||||||
|
SessionCategoryRegistry.register(runningSessionID, "quick")
|
||||||
|
SessionCategoryRegistry.register(completedSessionID, "deep")
|
||||||
|
SessionCategoryRegistry.register(unrelatedSessionID, "test")
|
||||||
|
|
||||||
|
// when
|
||||||
|
await manager.shutdown()
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(subagentSessions.has(runningSessionID)).toBe(false)
|
||||||
|
expect(subagentSessions.has(completedSessionID)).toBe(false)
|
||||||
|
expect(subagentSessions.has(unrelatedSessionID)).toBe(true)
|
||||||
|
expect(SessionCategoryRegistry.has(runningSessionID)).toBe(false)
|
||||||
|
expect(SessionCategoryRegistry.has(completedSessionID)).toBe(false)
|
||||||
|
expect(SessionCategoryRegistry.has(unrelatedSessionID)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2111,6 +2111,254 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
|||||||
// then
|
// then
|
||||||
await expect(result).rejects.toThrow("background_task.maxDescendants cannot be enforced safely")
|
await expect(result).rejects.toThrow("background_task.maxDescendants cannot be enforced safely")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("should release descendant quota when queued task is cancelled before session starts", async () => {
|
||||||
|
// given
|
||||||
|
manager.shutdown()
|
||||||
|
manager = new BackgroundManager(
|
||||||
|
{
|
||||||
|
client: createMockClientWithSessionChain({
|
||||||
|
"session-root": { directory: "/test/dir" },
|
||||||
|
}),
|
||||||
|
directory: tmpdir(),
|
||||||
|
} as unknown as PluginInput,
|
||||||
|
{ defaultConcurrency: 1, maxDescendants: 2 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
description: "Test task",
|
||||||
|
prompt: "Do something",
|
||||||
|
agent: "test-agent",
|
||||||
|
parentSessionID: "session-root",
|
||||||
|
parentMessageID: "parent-message",
|
||||||
|
}
|
||||||
|
|
||||||
|
await manager.launch(input)
|
||||||
|
const queuedTask = await manager.launch(input)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50))
|
||||||
|
expect(manager.getTask(queuedTask.id)?.status).toBe("pending")
|
||||||
|
|
||||||
|
// when
|
||||||
|
const cancelled = manager.cancelPendingTask(queuedTask.id)
|
||||||
|
const replacementTask = await manager.launch(input)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(cancelled).toBe(true)
|
||||||
|
expect(replacementTask.status).toBe("pending")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should release descendant quota when session creation fails before session starts", async () => {
|
||||||
|
// given
|
||||||
|
let createAttempts = 0
|
||||||
|
manager.shutdown()
|
||||||
|
manager = new BackgroundManager(
|
||||||
|
{
|
||||||
|
client: {
|
||||||
|
session: {
|
||||||
|
create: async () => {
|
||||||
|
createAttempts += 1
|
||||||
|
if (createAttempts === 1) {
|
||||||
|
return { error: "session create failed", data: undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data: { id: `ses_${crypto.randomUUID()}` } }
|
||||||
|
},
|
||||||
|
get: async () => ({ data: { directory: "/test/dir" } }),
|
||||||
|
prompt: async () => ({}),
|
||||||
|
promptAsync: async () => ({}),
|
||||||
|
messages: async () => ({ data: [] }),
|
||||||
|
todo: async () => ({ data: [] }),
|
||||||
|
status: async () => ({ data: {} }),
|
||||||
|
abort: async () => ({}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
directory: tmpdir(),
|
||||||
|
} as unknown as PluginInput,
|
||||||
|
{ maxDescendants: 1 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
description: "Test task",
|
||||||
|
prompt: "Do something",
|
||||||
|
agent: "test-agent",
|
||||||
|
parentSessionID: "session-root",
|
||||||
|
parentMessageID: "parent-message",
|
||||||
|
}
|
||||||
|
|
||||||
|
await manager.launch(input)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50))
|
||||||
|
expect(createAttempts).toBe(1)
|
||||||
|
|
||||||
|
// when
|
||||||
|
const retryTask = await manager.launch(input)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(retryTask.status).toBe("pending")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should keep the next queued task when the first task is cancelled during session creation", async () => {
|
||||||
|
// given
|
||||||
|
const firstSessionID = "ses-first-cancelled-during-create"
|
||||||
|
const secondSessionID = "ses-second-survives-queue"
|
||||||
|
let createCallCount = 0
|
||||||
|
let resolveFirstCreate: ((value: { data: { id: string } }) => void) | undefined
|
||||||
|
let resolveFirstCreateStarted: (() => void) | undefined
|
||||||
|
let resolveSecondPromptAsync: (() => void) | undefined
|
||||||
|
const firstCreateStarted = new Promise<void>((resolve) => {
|
||||||
|
resolveFirstCreateStarted = resolve
|
||||||
|
})
|
||||||
|
const secondPromptAsyncStarted = new Promise<void>((resolve) => {
|
||||||
|
resolveSecondPromptAsync = resolve
|
||||||
|
})
|
||||||
|
|
||||||
|
manager.shutdown()
|
||||||
|
manager = new BackgroundManager(
|
||||||
|
{
|
||||||
|
client: {
|
||||||
|
session: {
|
||||||
|
create: async () => {
|
||||||
|
createCallCount += 1
|
||||||
|
if (createCallCount === 1) {
|
||||||
|
resolveFirstCreateStarted?.()
|
||||||
|
return await new Promise<{ data: { id: string } }>((resolve) => {
|
||||||
|
resolveFirstCreate = resolve
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data: { id: secondSessionID } }
|
||||||
|
},
|
||||||
|
get: async () => ({ data: { directory: "/test/dir" } }),
|
||||||
|
prompt: async () => ({}),
|
||||||
|
promptAsync: async ({ path }: { path: { id: string } }) => {
|
||||||
|
if (path.id === secondSessionID) {
|
||||||
|
resolveSecondPromptAsync?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
messages: async () => ({ data: [] }),
|
||||||
|
todo: async () => ({ data: [] }),
|
||||||
|
status: async () => ({ data: {} }),
|
||||||
|
abort: async () => ({}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
directory: tmpdir(),
|
||||||
|
} as unknown as PluginInput,
|
||||||
|
{ defaultConcurrency: 1 }
|
||||||
|
)
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
description: "Test task",
|
||||||
|
prompt: "Do something",
|
||||||
|
agent: "test-agent",
|
||||||
|
parentSessionID: "parent-session",
|
||||||
|
parentMessageID: "parent-message",
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstTask = await manager.launch(input)
|
||||||
|
const secondTask = await manager.launch(input)
|
||||||
|
await firstCreateStarted
|
||||||
|
|
||||||
|
// when
|
||||||
|
const cancelled = await manager.cancelTask(firstTask.id, {
|
||||||
|
source: "test",
|
||||||
|
abortSession: false,
|
||||||
|
})
|
||||||
|
resolveFirstCreate?.({ data: { id: firstSessionID } })
|
||||||
|
|
||||||
|
await Promise.race([
|
||||||
|
secondPromptAsyncStarted,
|
||||||
|
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("timeout")), 100)),
|
||||||
|
])
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(cancelled).toBe(true)
|
||||||
|
expect(createCallCount).toBe(2)
|
||||||
|
expect(manager.getTask(firstTask.id)?.status).toBe("cancelled")
|
||||||
|
expect(manager.getTask(secondTask.id)?.status).toBe("running")
|
||||||
|
expect(manager.getTask(secondTask.id)?.sessionID).toBe(secondSessionID)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should keep task cancelled and abort the session when cancellation wins during session creation", async () => {
|
||||||
|
// given
|
||||||
|
const createdSessionID = "ses-cancelled-during-create"
|
||||||
|
let resolveCreate: ((value: { data: { id: string } }) => void) | undefined
|
||||||
|
let resolveCreateStarted: (() => void) | undefined
|
||||||
|
let resolveAbortCalled: (() => void) | undefined
|
||||||
|
const createStarted = new Promise<void>((resolve) => {
|
||||||
|
resolveCreateStarted = resolve
|
||||||
|
})
|
||||||
|
const abortCalled = new Promise<void>((resolve) => {
|
||||||
|
resolveAbortCalled = resolve
|
||||||
|
})
|
||||||
|
const abortCalls: string[] = []
|
||||||
|
const promptAsyncSessionIDs: string[] = []
|
||||||
|
|
||||||
|
manager.shutdown()
|
||||||
|
manager = new BackgroundManager(
|
||||||
|
{
|
||||||
|
client: {
|
||||||
|
session: {
|
||||||
|
create: async () => {
|
||||||
|
resolveCreateStarted?.()
|
||||||
|
return await new Promise<{ data: { id: string } }>((resolve) => {
|
||||||
|
resolveCreate = resolve
|
||||||
|
})
|
||||||
|
},
|
||||||
|
get: async () => ({ data: { directory: "/test/dir" } }),
|
||||||
|
prompt: async () => ({}),
|
||||||
|
promptAsync: async ({ path }: { path: { id: string } }) => {
|
||||||
|
promptAsyncSessionIDs.push(path.id)
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
messages: async () => ({ data: [] }),
|
||||||
|
todo: async () => ({ data: [] }),
|
||||||
|
status: async () => ({ data: {} }),
|
||||||
|
abort: async ({ path }: { path: { id: string } }) => {
|
||||||
|
abortCalls.push(path.id)
|
||||||
|
resolveAbortCalled?.()
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
directory: tmpdir(),
|
||||||
|
} as unknown as PluginInput,
|
||||||
|
{ defaultConcurrency: 1 }
|
||||||
|
)
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
description: "Test task",
|
||||||
|
prompt: "Do something",
|
||||||
|
agent: "test-agent",
|
||||||
|
parentSessionID: "parent-session",
|
||||||
|
parentMessageID: "parent-message",
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = await manager.launch(input)
|
||||||
|
await createStarted
|
||||||
|
|
||||||
|
// when
|
||||||
|
const cancelled = await manager.cancelTask(task.id, {
|
||||||
|
source: "test",
|
||||||
|
abortSession: false,
|
||||||
|
})
|
||||||
|
resolveCreate?.({ data: { id: createdSessionID } })
|
||||||
|
|
||||||
|
await Promise.race([
|
||||||
|
abortCalled,
|
||||||
|
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("timeout")), 100)),
|
||||||
|
])
|
||||||
|
await Promise.resolve()
|
||||||
|
|
||||||
|
// then
|
||||||
|
const updatedTask = manager.getTask(task.id)
|
||||||
|
expect(cancelled).toBe(true)
|
||||||
|
expect(updatedTask?.status).toBe("cancelled")
|
||||||
|
expect(updatedTask?.sessionID).toBeUndefined()
|
||||||
|
expect(promptAsyncSessionIDs).not.toContain(createdSessionID)
|
||||||
|
expect(abortCalls).toEqual([createdSessionID])
|
||||||
|
expect(getConcurrencyManager(manager).getCount("test-agent")).toBe(0)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("pending task can be cancelled", () => {
|
describe("pending task can be cancelled", () => {
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ export class BackgroundManager {
|
|||||||
private idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
|
private idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
|
||||||
private notificationQueueByParent: Map<string, Promise<void>> = new Map()
|
private notificationQueueByParent: Map<string, Promise<void>> = new Map()
|
||||||
private rootDescendantCounts: Map<string, number>
|
private rootDescendantCounts: Map<string, number>
|
||||||
|
private preStartDescendantReservations: Set<string>
|
||||||
private enableParentSessionNotifications: boolean
|
private enableParentSessionNotifications: boolean
|
||||||
readonly taskHistory = new TaskHistory()
|
readonly taskHistory = new TaskHistory()
|
||||||
|
|
||||||
@@ -150,6 +151,7 @@ export class BackgroundManager {
|
|||||||
this.onSubagentSessionCreated = options?.onSubagentSessionCreated
|
this.onSubagentSessionCreated = options?.onSubagentSessionCreated
|
||||||
this.onShutdown = options?.onShutdown
|
this.onShutdown = options?.onShutdown
|
||||||
this.rootDescendantCounts = new Map()
|
this.rootDescendantCounts = new Map()
|
||||||
|
this.preStartDescendantReservations = new Set()
|
||||||
this.enableParentSessionNotifications = options?.enableParentSessionNotifications ?? true
|
this.enableParentSessionNotifications = options?.enableParentSessionNotifications ?? true
|
||||||
this.registerProcessCleanup()
|
this.registerProcessCleanup()
|
||||||
}
|
}
|
||||||
@@ -220,6 +222,26 @@ export class BackgroundManager {
|
|||||||
this.rootDescendantCounts.set(rootSessionID, currentCount - 1)
|
this.rootDescendantCounts.set(rootSessionID, currentCount - 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private markPreStartDescendantReservation(task: BackgroundTask): void {
|
||||||
|
this.preStartDescendantReservations.add(task.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private settlePreStartDescendantReservation(task: BackgroundTask): void {
|
||||||
|
this.preStartDescendantReservations.delete(task.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private rollbackPreStartDescendantReservation(task: BackgroundTask): void {
|
||||||
|
if (!this.preStartDescendantReservations.delete(task.id)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!task.rootSessionID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.unregisterRootDescendant(task.rootSessionID)
|
||||||
|
}
|
||||||
|
|
||||||
async launch(input: LaunchInput): Promise<BackgroundTask> {
|
async launch(input: LaunchInput): Promise<BackgroundTask> {
|
||||||
log("[background-agent] launch() called with:", {
|
log("[background-agent] launch() called with:", {
|
||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
@@ -296,6 +318,7 @@ export class BackgroundManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
spawnReservation.commit()
|
spawnReservation.commit()
|
||||||
|
this.markPreStartDescendantReservation(task)
|
||||||
|
|
||||||
// Trigger processing (fire-and-forget)
|
// Trigger processing (fire-and-forget)
|
||||||
this.processKey(key)
|
this.processKey(key)
|
||||||
@@ -317,13 +340,16 @@ export class BackgroundManager {
|
|||||||
try {
|
try {
|
||||||
const queue = this.queuesByKey.get(key)
|
const queue = this.queuesByKey.get(key)
|
||||||
while (queue && queue.length > 0) {
|
while (queue && queue.length > 0) {
|
||||||
const item = queue[0]
|
const item = queue.shift()
|
||||||
|
if (!item) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
await this.concurrencyManager.acquire(key)
|
await this.concurrencyManager.acquire(key)
|
||||||
|
|
||||||
if (item.task.status === "cancelled" || item.task.status === "error" || item.task.status === "interrupt") {
|
if (item.task.status === "cancelled" || item.task.status === "error" || item.task.status === "interrupt") {
|
||||||
|
this.rollbackPreStartDescendantReservation(item.task)
|
||||||
this.concurrencyManager.release(key)
|
this.concurrencyManager.release(key)
|
||||||
queue.shift()
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,6 +357,7 @@ export class BackgroundManager {
|
|||||||
await this.startTask(item)
|
await this.startTask(item)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log("[background-agent] Error starting task:", error)
|
log("[background-agent] Error starting task:", error)
|
||||||
|
this.rollbackPreStartDescendantReservation(item.task)
|
||||||
if (item.task.concurrencyKey) {
|
if (item.task.concurrencyKey) {
|
||||||
this.concurrencyManager.release(item.task.concurrencyKey)
|
this.concurrencyManager.release(item.task.concurrencyKey)
|
||||||
item.task.concurrencyKey = undefined
|
item.task.concurrencyKey = undefined
|
||||||
@@ -338,8 +365,6 @@ export class BackgroundManager {
|
|||||||
this.concurrencyManager.release(key)
|
this.concurrencyManager.release(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
queue.shift()
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this.processingKeys.delete(key)
|
this.processingKeys.delete(key)
|
||||||
@@ -386,6 +411,18 @@ export class BackgroundManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sessionID = createResult.data.id
|
const sessionID = createResult.data.id
|
||||||
|
|
||||||
|
if (task.status === "cancelled") {
|
||||||
|
await this.client.session.abort({
|
||||||
|
path: { id: sessionID },
|
||||||
|
}).catch((error) => {
|
||||||
|
log("[background-agent] Failed to abort cancelled pre-start session:", error)
|
||||||
|
})
|
||||||
|
this.concurrencyManager.release(concurrencyKey)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.settlePreStartDescendantReservation(task)
|
||||||
subagentSessions.add(sessionID)
|
subagentSessions.add(sessionID)
|
||||||
|
|
||||||
log("[background-agent] tmux callback check", {
|
log("[background-agent] tmux callback check", {
|
||||||
@@ -1204,6 +1241,7 @@ export class BackgroundManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.rollbackPreStartDescendantReservation(task)
|
||||||
log("[background-agent] Cancelled pending task:", { taskId, key })
|
log("[background-agent] Cancelled pending task:", { taskId, key })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1707,9 +1745,14 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
|||||||
this.shutdownTriggered = true
|
this.shutdownTriggered = true
|
||||||
log("[background-agent] Shutting down BackgroundManager")
|
log("[background-agent] Shutting down BackgroundManager")
|
||||||
this.stopPolling()
|
this.stopPolling()
|
||||||
|
const trackedSessionIDs = new Set<string>()
|
||||||
|
|
||||||
// Abort all running sessions to prevent zombie processes (#1240)
|
// Abort all running sessions to prevent zombie processes (#1240)
|
||||||
for (const task of this.tasks.values()) {
|
for (const task of this.tasks.values()) {
|
||||||
|
if (task.sessionID) {
|
||||||
|
trackedSessionIDs.add(task.sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
if (task.status === "running" && task.sessionID) {
|
if (task.status === "running" && task.sessionID) {
|
||||||
this.client.session.abort({
|
this.client.session.abort({
|
||||||
path: { id: task.sessionID },
|
path: { id: task.sessionID },
|
||||||
@@ -1744,6 +1787,11 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
|||||||
}
|
}
|
||||||
this.idleDeferralTimers.clear()
|
this.idleDeferralTimers.clear()
|
||||||
|
|
||||||
|
for (const sessionID of trackedSessionIDs) {
|
||||||
|
subagentSessions.delete(sessionID)
|
||||||
|
SessionCategoryRegistry.remove(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
this.concurrencyManager.clear()
|
this.concurrencyManager.clear()
|
||||||
this.tasks.clear()
|
this.tasks.clear()
|
||||||
this.notifications.clear()
|
this.notifications.clear()
|
||||||
|
|||||||
@@ -21,6 +21,6 @@ export function createAtlasHook(ctx: PluginInput, options?: AtlasHookOptions) {
|
|||||||
return {
|
return {
|
||||||
handler: createAtlasEventHandler({ ctx, options, sessions, getState }),
|
handler: createAtlasEventHandler({ ctx, options, sessions, getState }),
|
||||||
"tool.execute.before": createToolExecuteBeforeHandler({ ctx, pendingFilePaths }),
|
"tool.execute.before": createToolExecuteBeforeHandler({ ctx, pendingFilePaths }),
|
||||||
"tool.execute.after": createToolExecuteAfterHandler({ ctx, pendingFilePaths, autoCommit }),
|
"tool.execute.after": createToolExecuteAfterHandler({ ctx, pendingFilePaths, autoCommit, getState }),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
import { log } from "../../shared/logger"
|
||||||
|
import { HOOK_NAME } from "./hook-name"
|
||||||
|
|
||||||
|
export async function isSessionInBoulderLineage(input: {
|
||||||
|
client: PluginInput["client"]
|
||||||
|
sessionID: string
|
||||||
|
boulderSessionIDs: string[]
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const visitedSessionIDs = new Set<string>()
|
||||||
|
let currentSessionID = input.sessionID
|
||||||
|
|
||||||
|
while (!visitedSessionIDs.has(currentSessionID)) {
|
||||||
|
visitedSessionIDs.add(currentSessionID)
|
||||||
|
|
||||||
|
const sessionResult = await input.client.session
|
||||||
|
.get({ path: { id: currentSessionID } })
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
log(`[${HOOK_NAME}] Failed to resolve session lineage`, {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
currentSessionID,
|
||||||
|
error,
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!sessionResult || sessionResult.error) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentSessionID = sessionResult.data?.parentID
|
||||||
|
if (!parentSessionID) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.boulderSessionIDs.includes(parentSessionID)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSessionID = parentSessionID
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -38,11 +38,15 @@ export function createAtlasEventHandler(input: {
|
|||||||
if (event.type === "message.updated") {
|
if (event.type === "message.updated") {
|
||||||
const info = props?.info as Record<string, unknown> | undefined
|
const info = props?.info as Record<string, unknown> | undefined
|
||||||
const sessionID = info?.sessionID as string | undefined
|
const sessionID = info?.sessionID as string | undefined
|
||||||
|
const role = info?.role as string | undefined
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
const state = sessions.get(sessionID)
|
const state = sessions.get(sessionID)
|
||||||
if (state) {
|
if (state) {
|
||||||
state.lastEventWasAbortError = false
|
state.lastEventWasAbortError = false
|
||||||
|
if (role === "user") {
|
||||||
|
state.waitingForFinalWaveApproval = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||||
|
import { randomUUID } from "node:crypto"
|
||||||
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||||
|
import type { AssistantMessage, Session } from "@opencode-ai/sdk"
|
||||||
|
import type { BoulderState } from "../../features/boulder-state"
|
||||||
|
import { clearBoulderState, writeBoulderState } from "../../features/boulder-state"
|
||||||
|
|
||||||
|
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-final-wave-storage-${randomUUID()}`)
|
||||||
|
const TEST_MESSAGE_STORAGE = join(TEST_STORAGE_ROOT, "message")
|
||||||
|
const TEST_PART_STORAGE = join(TEST_STORAGE_ROOT, "part")
|
||||||
|
|
||||||
|
mock.module("../../features/hook-message-injector/constants", () => ({
|
||||||
|
OPENCODE_STORAGE: TEST_STORAGE_ROOT,
|
||||||
|
MESSAGE_STORAGE: TEST_MESSAGE_STORAGE,
|
||||||
|
PART_STORAGE: TEST_PART_STORAGE,
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module("../../shared/opencode-message-dir", () => ({
|
||||||
|
getMessageDir: (sessionID: string) => {
|
||||||
|
const directoryPath = join(TEST_MESSAGE_STORAGE, sessionID)
|
||||||
|
return existsSync(directoryPath) ? directoryPath : null
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module("../../shared/opencode-storage-detection", () => ({
|
||||||
|
isSqliteBackend: () => false,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { createAtlasHook } = await import("./index")
|
||||||
|
const { MESSAGE_STORAGE } = await import("../../features/hook-message-injector")
|
||||||
|
|
||||||
|
type AtlasHookContext = Parameters<typeof createAtlasHook>[0]
|
||||||
|
type PromptMock = ReturnType<typeof mock>
|
||||||
|
|
||||||
|
describe("Atlas final verification approval gate", () => {
|
||||||
|
let testDirectory = ""
|
||||||
|
|
||||||
|
function createMockPluginInput(): AtlasHookContext & { _promptMock: PromptMock } {
|
||||||
|
const client = createOpencodeClient({ baseUrl: "http://localhost" })
|
||||||
|
const promptMock = mock((input: unknown) => input)
|
||||||
|
|
||||||
|
Reflect.set(client.session, "prompt", async (input: unknown) => {
|
||||||
|
promptMock(input)
|
||||||
|
return {
|
||||||
|
data: { info: {} as AssistantMessage, parts: [] },
|
||||||
|
request: new Request("http://localhost/session/prompt"),
|
||||||
|
response: new Response(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
Reflect.set(client.session, "promptAsync", async (input: unknown) => {
|
||||||
|
promptMock(input)
|
||||||
|
return {
|
||||||
|
data: undefined,
|
||||||
|
request: new Request("http://localhost/session/prompt_async"),
|
||||||
|
response: new Response(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
Reflect.set(client.session, "get", async () => {
|
||||||
|
return {
|
||||||
|
data: { parentID: "main-session-123" } as Session,
|
||||||
|
request: new Request("http://localhost/session/main-session-123"),
|
||||||
|
response: new Response(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
directory: testDirectory,
|
||||||
|
project: {} as AtlasHookContext["project"],
|
||||||
|
worktree: testDirectory,
|
||||||
|
serverUrl: new URL("http://localhost"),
|
||||||
|
$: {} as AtlasHookContext["$"],
|
||||||
|
client,
|
||||||
|
_promptMock: promptMock,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupMessageStorage(sessionID: string): void {
|
||||||
|
const messageDirectory = join(MESSAGE_STORAGE, sessionID)
|
||||||
|
if (!existsSync(messageDirectory)) {
|
||||||
|
mkdirSync(messageDirectory, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
join(messageDirectory, "msg_test001.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
agent: "atlas",
|
||||||
|
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupMessageStorage(sessionID: string): void {
|
||||||
|
const messageDirectory = join(MESSAGE_STORAGE, sessionID)
|
||||||
|
if (existsSync(messageDirectory)) {
|
||||||
|
rmSync(messageDirectory, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
testDirectory = join(tmpdir(), `atlas-final-wave-test-${randomUUID()}`)
|
||||||
|
mkdirSync(join(testDirectory, ".sisyphus"), { recursive: true })
|
||||||
|
clearBoulderState(testDirectory)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearBoulderState(testDirectory)
|
||||||
|
if (existsSync(testDirectory)) {
|
||||||
|
rmSync(testDirectory, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("waits for explicit user approval after the last final-wave approval arrives", async () => {
|
||||||
|
// given
|
||||||
|
const sessionID = "atlas-final-wave-session"
|
||||||
|
setupMessageStorage(sessionID)
|
||||||
|
|
||||||
|
const planPath = join(testDirectory, "final-wave-plan.md")
|
||||||
|
writeFileSync(
|
||||||
|
planPath,
|
||||||
|
`# Plan
|
||||||
|
|
||||||
|
## TODOs
|
||||||
|
- [x] 1. Ship the implementation
|
||||||
|
|
||||||
|
## Final Verification Wave (MANDATORY - after ALL implementation tasks)
|
||||||
|
- [x] F1. **Plan Compliance Audit** - \`oracle\`
|
||||||
|
- [x] F2. **Code Quality Review** - \`unspecified-high\`
|
||||||
|
- [x] F3. **Real Manual QA** - \`unspecified-high\`
|
||||||
|
- [ ] F4. **Scope Fidelity Check** - \`deep\`
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
|
||||||
|
const state: BoulderState = {
|
||||||
|
active_plan: planPath,
|
||||||
|
started_at: "2026-01-02T10:00:00Z",
|
||||||
|
session_ids: [sessionID],
|
||||||
|
plan_name: "final-wave-plan",
|
||||||
|
agent: "atlas",
|
||||||
|
}
|
||||||
|
writeBoulderState(testDirectory, state)
|
||||||
|
|
||||||
|
const mockInput = createMockPluginInput()
|
||||||
|
const hook = createAtlasHook(mockInput)
|
||||||
|
const toolOutput = {
|
||||||
|
title: "Sisyphus Task",
|
||||||
|
output: `Tasks [4/4 compliant] | Contamination [CLEAN] | Unaccounted [CLEAN] | VERDICT: APPROVE
|
||||||
|
|
||||||
|
<task_metadata>
|
||||||
|
session_id: ses_final_wave_review
|
||||||
|
</task_metadata>`,
|
||||||
|
metadata: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
await hook["tool.execute.after"]({ tool: "task", sessionID }, toolOutput)
|
||||||
|
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(toolOutput.output).toContain("FINAL WAVE APPROVAL GATE")
|
||||||
|
expect(toolOutput.output).toContain("explicit user approval")
|
||||||
|
expect(toolOutput.output).not.toContain("STEP 8: PROCEED TO NEXT TASK")
|
||||||
|
expect(mockInput._promptMock).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
cleanupMessageStorage(sessionID)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps normal auto-continue instructions for non-final tasks", async () => {
|
||||||
|
// given
|
||||||
|
const sessionID = "atlas-non-final-session"
|
||||||
|
setupMessageStorage(sessionID)
|
||||||
|
|
||||||
|
const planPath = join(testDirectory, "implementation-plan.md")
|
||||||
|
writeFileSync(
|
||||||
|
planPath,
|
||||||
|
`# Plan
|
||||||
|
|
||||||
|
## TODOs
|
||||||
|
- [x] 1. Setup
|
||||||
|
- [ ] 2. Implement feature
|
||||||
|
|
||||||
|
## Final Verification Wave (MANDATORY - after ALL implementation tasks)
|
||||||
|
- [ ] F1. **Plan Compliance Audit** - \`oracle\`
|
||||||
|
- [ ] F2. **Code Quality Review** - \`unspecified-high\`
|
||||||
|
- [ ] F3. **Real Manual QA** - \`unspecified-high\`
|
||||||
|
- [ ] F4. **Scope Fidelity Check** - \`deep\`
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
|
||||||
|
const state: BoulderState = {
|
||||||
|
active_plan: planPath,
|
||||||
|
started_at: "2026-01-02T10:00:00Z",
|
||||||
|
session_ids: [sessionID],
|
||||||
|
plan_name: "implementation-plan",
|
||||||
|
agent: "atlas",
|
||||||
|
}
|
||||||
|
writeBoulderState(testDirectory, state)
|
||||||
|
|
||||||
|
const hook = createAtlasHook(createMockPluginInput())
|
||||||
|
const toolOutput = {
|
||||||
|
title: "Sisyphus Task",
|
||||||
|
output: `Implementation finished successfully
|
||||||
|
|
||||||
|
<task_metadata>
|
||||||
|
session_id: ses_feature_task
|
||||||
|
</task_metadata>`,
|
||||||
|
metadata: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
await hook["tool.execute.after"]({ tool: "task", sessionID }, toolOutput)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(toolOutput.output).toContain("COMPLETION GATE")
|
||||||
|
expect(toolOutput.output).toContain("STEP 8: PROCEED TO NEXT TASK")
|
||||||
|
expect(toolOutput.output).not.toContain("FINAL WAVE APPROVAL GATE")
|
||||||
|
|
||||||
|
cleanupMessageStorage(sessionID)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { existsSync, readFileSync } from "node:fs"
|
||||||
|
|
||||||
|
const APPROVE_VERDICT_PATTERN = /\bVERDICT:\s*APPROVE\b/i
|
||||||
|
const FINAL_VERIFICATION_HEADING_PATTERN = /^##\s+Final Verification Wave\b/i
|
||||||
|
const UNCHECKED_TASK_PATTERN = /^\s*[-*]\s*\[\s*\]\s*(.+)$/
|
||||||
|
const FINAL_WAVE_TASK_PATTERN = /^F\d+\./i
|
||||||
|
|
||||||
|
export function shouldPauseForFinalWaveApproval(input: {
|
||||||
|
planPath: string
|
||||||
|
taskOutput: string
|
||||||
|
}): boolean {
|
||||||
|
if (!APPROVE_VERDICT_PATTERN.test(input.taskOutput)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(input.planPath)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = readFileSync(input.planPath, "utf-8")
|
||||||
|
const lines = content.split(/\r?\n/)
|
||||||
|
let inFinalVerificationWave = false
|
||||||
|
let uncheckedTaskCount = 0
|
||||||
|
let uncheckedFinalWaveTaskCount = 0
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (/^##\s+/.test(line)) {
|
||||||
|
inFinalVerificationWave = FINAL_VERIFICATION_HEADING_PATTERN.test(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
const uncheckedTaskMatch = line.match(UNCHECKED_TASK_PATTERN)
|
||||||
|
if (!uncheckedTaskMatch) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
uncheckedTaskCount += 1
|
||||||
|
if (inFinalVerificationWave && FINAL_WAVE_TASK_PATTERN.test(uncheckedTaskMatch[1].trim())) {
|
||||||
|
uncheckedFinalWaveTaskCount += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return uncheckedTaskCount === 1 && uncheckedFinalWaveTaskCount === 1
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { afterEach, beforeEach, describe, it } from "bun:test"
|
||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { randomUUID } from "node:crypto"
|
||||||
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import { clearBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state"
|
||||||
|
import type { BoulderState } from "../../features/boulder-state"
|
||||||
|
import { _resetForTesting, subagentSessions } from "../../features/claude-code-session-state"
|
||||||
|
|
||||||
|
const { createAtlasHook } = await import("./index")
|
||||||
|
|
||||||
|
describe("atlas hook idle-event session lineage", () => {
|
||||||
|
const MAIN_SESSION_ID = "main-session-123"
|
||||||
|
|
||||||
|
let testDirectory = ""
|
||||||
|
let promptCalls: Array<unknown> = []
|
||||||
|
|
||||||
|
function writeIncompleteBoulder(): void {
|
||||||
|
const planPath = join(testDirectory, "test-plan.md")
|
||||||
|
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
|
||||||
|
|
||||||
|
const state: BoulderState = {
|
||||||
|
active_plan: planPath,
|
||||||
|
started_at: "2026-01-02T10:00:00Z",
|
||||||
|
session_ids: [MAIN_SESSION_ID],
|
||||||
|
plan_name: "test-plan",
|
||||||
|
}
|
||||||
|
|
||||||
|
writeBoulderState(testDirectory, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHook(parentSessionIDs?: Record<string, string | undefined>) {
|
||||||
|
return createAtlasHook({
|
||||||
|
directory: testDirectory,
|
||||||
|
client: {
|
||||||
|
session: {
|
||||||
|
get: async (input: { path: { id: string } }) => ({
|
||||||
|
data: {
|
||||||
|
parentID: parentSessionIDs?.[input.path.id],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
messages: async () => ({ data: [] }),
|
||||||
|
prompt: async (input: unknown) => {
|
||||||
|
promptCalls.push(input)
|
||||||
|
return { data: {} }
|
||||||
|
},
|
||||||
|
promptAsync: async (input: unknown) => {
|
||||||
|
promptCalls.push(input)
|
||||||
|
return { data: {} }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as Parameters<typeof createAtlasHook>[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
testDirectory = join(tmpdir(), `atlas-idle-lineage-${randomUUID()}`)
|
||||||
|
if (!existsSync(testDirectory)) {
|
||||||
|
mkdirSync(testDirectory, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
promptCalls = []
|
||||||
|
clearBoulderState(testDirectory)
|
||||||
|
_resetForTesting()
|
||||||
|
subagentSessions.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearBoulderState(testDirectory)
|
||||||
|
if (existsSync(testDirectory)) {
|
||||||
|
rmSync(testDirectory, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
_resetForTesting()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not append unrelated subagent sessions during idle", async () => {
|
||||||
|
const unrelatedSubagentSessionID = "subagent-session-unrelated"
|
||||||
|
const unrelatedParentSessionID = "unrelated-parent-session"
|
||||||
|
|
||||||
|
writeIncompleteBoulder()
|
||||||
|
subagentSessions.add(unrelatedSubagentSessionID)
|
||||||
|
|
||||||
|
const hook = createHook({
|
||||||
|
[unrelatedSubagentSessionID]: unrelatedParentSessionID,
|
||||||
|
})
|
||||||
|
|
||||||
|
await hook.handler({
|
||||||
|
event: {
|
||||||
|
type: "session.idle",
|
||||||
|
properties: { sessionID: unrelatedSubagentSessionID },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(readBoulderState(testDirectory)?.session_ids.includes(unrelatedSubagentSessionID), false)
|
||||||
|
assert.equal(promptCalls.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("appends boulder-owned subagent sessions during idle when lineage reaches tracked session", async () => {
|
||||||
|
const subagentSessionID = "subagent-session-456"
|
||||||
|
const intermediateParentSessionID = "subagent-parent-789"
|
||||||
|
|
||||||
|
writeIncompleteBoulder()
|
||||||
|
subagentSessions.add(subagentSessionID)
|
||||||
|
|
||||||
|
const hook = createHook({
|
||||||
|
[subagentSessionID]: intermediateParentSessionID,
|
||||||
|
[intermediateParentSessionID]: MAIN_SESSION_ID,
|
||||||
|
})
|
||||||
|
|
||||||
|
await hook.handler({
|
||||||
|
event: {
|
||||||
|
type: "session.idle",
|
||||||
|
properties: { sessionID: subagentSessionID },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(readBoulderState(testDirectory)?.session_ids.includes(subagentSessionID), true)
|
||||||
|
assert.equal(promptCalls.length, 1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import { appendSessionId, getPlanProgress, readBoulderState } from "../../features/boulder-state"
|
import { getPlanProgress, readBoulderState } from "../../features/boulder-state"
|
||||||
import type { BoulderState, PlanProgress } from "../../features/boulder-state"
|
|
||||||
import { subagentSessions } from "../../features/claude-code-session-state"
|
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
import { injectBoulderContinuation } from "./boulder-continuation-injector"
|
import { injectBoulderContinuation } from "./boulder-continuation-injector"
|
||||||
import { HOOK_NAME } from "./hook-name"
|
import { HOOK_NAME } from "./hook-name"
|
||||||
|
import { resolveActiveBoulderSession } from "./resolve-active-boulder-session"
|
||||||
import type { AtlasHookOptions, SessionState } from "./types"
|
import type { AtlasHookOptions, SessionState } from "./types"
|
||||||
|
|
||||||
const CONTINUATION_COOLDOWN_MS = 5000
|
const CONTINUATION_COOLDOWN_MS = 5000
|
||||||
@@ -18,44 +17,6 @@ function hasRunningBackgroundTasks(sessionID: string, options?: AtlasHookOptions
|
|||||||
: false
|
: false
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveActiveBoulderSession(input: {
|
|
||||||
directory: string
|
|
||||||
sessionID: string
|
|
||||||
}): {
|
|
||||||
boulderState: BoulderState
|
|
||||||
progress: PlanProgress
|
|
||||||
appendedSession: boolean
|
|
||||||
} | null {
|
|
||||||
const boulderState = readBoulderState(input.directory)
|
|
||||||
if (!boulderState) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const progress = getPlanProgress(boulderState.active_plan)
|
|
||||||
if (progress.isComplete) {
|
|
||||||
return { boulderState, progress, appendedSession: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (boulderState.session_ids.includes(input.sessionID)) {
|
|
||||||
return { boulderState, progress, appendedSession: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!subagentSessions.has(input.sessionID)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedBoulderState = appendSessionId(input.directory, input.sessionID)
|
|
||||||
if (!updatedBoulderState?.session_ids.includes(input.sessionID)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
boulderState: updatedBoulderState,
|
|
||||||
progress,
|
|
||||||
appendedSession: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function injectContinuation(input: {
|
async function injectContinuation(input: {
|
||||||
ctx: PluginInput
|
ctx: PluginInput
|
||||||
sessionID: string
|
sessionID: string
|
||||||
@@ -102,6 +63,7 @@ function scheduleRetry(input: {
|
|||||||
sessionState.pendingRetryTimer = undefined
|
sessionState.pendingRetryTimer = undefined
|
||||||
|
|
||||||
if (sessionState.promptFailureCount >= 2) return
|
if (sessionState.promptFailureCount >= 2) return
|
||||||
|
if (sessionState.waitingForFinalWaveApproval) return
|
||||||
|
|
||||||
const currentBoulder = readBoulderState(ctx.directory)
|
const currentBoulder = readBoulderState(ctx.directory)
|
||||||
if (!currentBoulder) return
|
if (!currentBoulder) return
|
||||||
@@ -136,7 +98,8 @@ export async function handleAtlasSessionIdle(input: {
|
|||||||
|
|
||||||
log(`[${HOOK_NAME}] session.idle`, { sessionID })
|
log(`[${HOOK_NAME}] session.idle`, { sessionID })
|
||||||
|
|
||||||
const activeBoulderSession = resolveActiveBoulderSession({
|
const activeBoulderSession = await resolveActiveBoulderSession({
|
||||||
|
client: ctx.client,
|
||||||
directory: ctx.directory,
|
directory: ctx.directory,
|
||||||
sessionID,
|
sessionID,
|
||||||
})
|
})
|
||||||
@@ -161,6 +124,11 @@ export async function handleAtlasSessionIdle(input: {
|
|||||||
const sessionState = getState(sessionID)
|
const sessionState = getState(sessionID)
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
|
|
||||||
|
if (sessionState.waitingForFinalWaveApproval) {
|
||||||
|
log(`[${HOOK_NAME}] Skipped: waiting for explicit final-wave approval`, { sessionID })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (sessionState.lastEventWasAbortError) {
|
if (sessionState.lastEventWasAbortError) {
|
||||||
sessionState.lastEventWasAbortError = false
|
sessionState.lastEventWasAbortError = false
|
||||||
log(`[${HOOK_NAME}] Skipped: abort error immediately before idle`, { sessionID })
|
log(`[${HOOK_NAME}] Skipped: abort error immediately before idle`, { sessionID })
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ describe("atlas hook", () => {
|
|||||||
directory: TEST_DIR,
|
directory: TEST_DIR,
|
||||||
client: {
|
client: {
|
||||||
session: {
|
session: {
|
||||||
|
get: async () => ({ data: { parentID: "main-session-123" } }),
|
||||||
prompt: promptMock,
|
prompt: promptMock,
|
||||||
promptAsync: promptMock,
|
promptAsync: promptMock,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
import { appendSessionId, getPlanProgress, readBoulderState } from "../../features/boulder-state"
|
||||||
|
import type { BoulderState, PlanProgress } from "../../features/boulder-state"
|
||||||
|
import { subagentSessions } from "../../features/claude-code-session-state"
|
||||||
|
import { isSessionInBoulderLineage } from "./boulder-session-lineage"
|
||||||
|
|
||||||
|
export async function resolveActiveBoulderSession(input: {
|
||||||
|
client: PluginInput["client"]
|
||||||
|
directory: string
|
||||||
|
sessionID: string
|
||||||
|
}): Promise<{
|
||||||
|
boulderState: BoulderState
|
||||||
|
progress: PlanProgress
|
||||||
|
appendedSession: boolean
|
||||||
|
} | null> {
|
||||||
|
const boulderState = readBoulderState(input.directory)
|
||||||
|
if (!boulderState) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = getPlanProgress(boulderState.active_plan)
|
||||||
|
if (progress.isComplete) {
|
||||||
|
return { boulderState, progress, appendedSession: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (boulderState.session_ids.includes(input.sessionID)) {
|
||||||
|
return { boulderState, progress, appendedSession: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!subagentSessions.has(input.sessionID)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const belongsToActiveBoulder = await isSessionInBoulderLineage({
|
||||||
|
client: input.client,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
boulderSessionIDs: boulderState.session_ids,
|
||||||
|
})
|
||||||
|
if (!belongsToActiveBoulder) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedBoulderState = appendSessionId(input.directory, input.sessionID)
|
||||||
|
if (!updatedBoulderState?.session_ids.includes(input.sessionID)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
boulderState: updatedBoulderState,
|
||||||
|
progress,
|
||||||
|
appendedSession: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,20 +3,28 @@ import { appendSessionId, getPlanProgress, readBoulderState } from "../../featur
|
|||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
import { isCallerOrchestrator } from "../../shared/session-utils"
|
import { isCallerOrchestrator } from "../../shared/session-utils"
|
||||||
import { collectGitDiffStats, formatFileChanges } from "../../shared/git-worktree"
|
import { collectGitDiffStats, formatFileChanges } from "../../shared/git-worktree"
|
||||||
|
import { shouldPauseForFinalWaveApproval } from "./final-wave-approval-gate"
|
||||||
import { HOOK_NAME } from "./hook-name"
|
import { HOOK_NAME } from "./hook-name"
|
||||||
import { DIRECT_WORK_REMINDER } from "./system-reminder-templates"
|
import { DIRECT_WORK_REMINDER } from "./system-reminder-templates"
|
||||||
import { isSisyphusPath } from "./sisyphus-path"
|
import { isSisyphusPath } from "./sisyphus-path"
|
||||||
import { extractSessionIdFromOutput } from "./subagent-session-id"
|
import { extractSessionIdFromOutput } from "./subagent-session-id"
|
||||||
import { buildCompletionGate, buildOrchestratorReminder, buildStandaloneVerificationReminder } from "./verification-reminders"
|
import {
|
||||||
|
buildCompletionGate,
|
||||||
|
buildFinalWaveApprovalReminder,
|
||||||
|
buildOrchestratorReminder,
|
||||||
|
buildStandaloneVerificationReminder,
|
||||||
|
} from "./verification-reminders"
|
||||||
import { isWriteOrEditToolName } from "./write-edit-tool-policy"
|
import { isWriteOrEditToolName } from "./write-edit-tool-policy"
|
||||||
|
import type { SessionState } from "./types"
|
||||||
import type { ToolExecuteAfterInput, ToolExecuteAfterOutput } from "./types"
|
import type { ToolExecuteAfterInput, ToolExecuteAfterOutput } from "./types"
|
||||||
|
|
||||||
export function createToolExecuteAfterHandler(input: {
|
export function createToolExecuteAfterHandler(input: {
|
||||||
ctx: PluginInput
|
ctx: PluginInput
|
||||||
pendingFilePaths: Map<string, string>
|
pendingFilePaths: Map<string, string>
|
||||||
autoCommit: boolean
|
autoCommit: boolean
|
||||||
}): (toolInput: ToolExecuteAfterInput, toolOutput: ToolExecuteAfterOutput) => Promise<void> {
|
getState: (sessionID: string) => SessionState
|
||||||
const { ctx, pendingFilePaths, autoCommit } = input
|
}): (toolInput: ToolExecuteAfterInput, toolOutput: ToolExecuteAfterOutput) => Promise<void> {
|
||||||
|
const { ctx, pendingFilePaths, autoCommit, getState } = input
|
||||||
return async (toolInput, toolOutput): Promise<void> => {
|
return async (toolInput, toolOutput): Promise<void> => {
|
||||||
// Guard against undefined output (e.g., from /review command - see issue #1035)
|
// Guard against undefined output (e.g., from /review command - see issue #1035)
|
||||||
if (!toolOutput) {
|
if (!toolOutput) {
|
||||||
@@ -75,10 +83,31 @@ export function createToolExecuteAfterHandler(input: {
|
|||||||
|
|
||||||
// Preserve original subagent response - critical for debugging failed tasks
|
// Preserve original subagent response - critical for debugging failed tasks
|
||||||
const originalResponse = toolOutput.output
|
const originalResponse = toolOutput.output
|
||||||
|
const shouldPauseForApproval = shouldPauseForFinalWaveApproval({
|
||||||
|
planPath: boulderState.active_plan,
|
||||||
|
taskOutput: originalResponse,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (toolInput.sessionID) {
|
||||||
|
const sessionState = getState(toolInput.sessionID)
|
||||||
|
sessionState.waitingForFinalWaveApproval = shouldPauseForApproval
|
||||||
|
|
||||||
|
if (shouldPauseForApproval && sessionState.pendingRetryTimer) {
|
||||||
|
clearTimeout(sessionState.pendingRetryTimer)
|
||||||
|
sessionState.pendingRetryTimer = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const leadReminder = shouldPauseForApproval
|
||||||
|
? buildFinalWaveApprovalReminder(boulderState.plan_name, progress, subagentSessionId)
|
||||||
|
: buildCompletionGate(boulderState.plan_name, subagentSessionId)
|
||||||
|
const followupReminder = shouldPauseForApproval
|
||||||
|
? null
|
||||||
|
: buildOrchestratorReminder(boulderState.plan_name, progress, subagentSessionId, autoCommit, false)
|
||||||
|
|
||||||
toolOutput.output = `
|
toolOutput.output = `
|
||||||
<system-reminder>
|
<system-reminder>
|
||||||
${buildCompletionGate(boulderState.plan_name, subagentSessionId)}
|
${leadReminder}
|
||||||
</system-reminder>
|
</system-reminder>
|
||||||
|
|
||||||
## SUBAGENT WORK COMPLETED
|
## SUBAGENT WORK COMPLETED
|
||||||
@@ -91,13 +120,16 @@ ${fileChanges}
|
|||||||
|
|
||||||
${originalResponse}
|
${originalResponse}
|
||||||
|
|
||||||
<system-reminder>
|
${
|
||||||
${buildOrchestratorReminder(boulderState.plan_name, progress, subagentSessionId, autoCommit, false)}
|
followupReminder === null
|
||||||
</system-reminder>`
|
? ""
|
||||||
|
: `<system-reminder>\n${followupReminder}\n</system-reminder>`
|
||||||
|
}`
|
||||||
log(`[${HOOK_NAME}] Output transformed for orchestrator mode (boulder)`, {
|
log(`[${HOOK_NAME}] Output transformed for orchestrator mode (boulder)`, {
|
||||||
plan: boulderState.plan_name,
|
plan: boulderState.plan_name,
|
||||||
progress: `${progress.completed}/${progress.total}`,
|
progress: `${progress.completed}/${progress.total}`,
|
||||||
fileCount: gitStats.length,
|
fileCount: gitStats.length,
|
||||||
|
waitingForFinalWaveApproval: shouldPauseForApproval,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
toolOutput.output += `\n<system-reminder>\n${buildStandaloneVerificationReminder(subagentSessionId)}\n</system-reminder>`
|
toolOutput.output += `\n<system-reminder>\n${buildStandaloneVerificationReminder(subagentSessionId)}\n</system-reminder>`
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["bun-types"]
|
||||||
|
},
|
||||||
|
"include": ["./**/*.ts", "./**/*.d.ts"],
|
||||||
|
"exclude": []
|
||||||
|
}
|
||||||
@@ -31,4 +31,5 @@ export interface SessionState {
|
|||||||
promptFailureCount: number
|
promptFailureCount: number
|
||||||
lastFailureAt?: number
|
lastFailureAt?: number
|
||||||
pendingRetryTimer?: ReturnType<typeof setTimeout>
|
pendingRetryTimer?: ReturnType<typeof setTimeout>
|
||||||
|
waitingForFinalWaveApproval?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,45 @@ ${commitStep}
|
|||||||
**${remaining} tasks remain. Keep bouldering.**`
|
**${remaining} tasks remain. Keep bouldering.**`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildFinalWaveApprovalReminder(
|
||||||
|
planName: string,
|
||||||
|
progress: { total: number; completed: number },
|
||||||
|
sessionId: string
|
||||||
|
): string {
|
||||||
|
const remaining = progress.total - progress.completed
|
||||||
|
|
||||||
|
return `
|
||||||
|
---
|
||||||
|
|
||||||
|
**BOULDER STATE:** Plan: \
|
||||||
|
\`${planName}\` | ${progress.completed}/${progress.total} done | ${remaining} remaining
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
${buildVerificationReminder(sessionId)}
|
||||||
|
|
||||||
|
**FINAL WAVE APPROVAL GATE**
|
||||||
|
|
||||||
|
The last Final Verification Wave result just passed.
|
||||||
|
This is the ONLY point where approval-style user interaction is required.
|
||||||
|
|
||||||
|
1. Read \
|
||||||
|
\`.sisyphus/plans/${planName}.md\` again and confirm the remaining unchecked item is the last final-wave task.
|
||||||
|
2. Consolidate the F1-F4 verdicts into a short summary for the user.
|
||||||
|
3. Tell the user all final reviewers approved.
|
||||||
|
4. Ask for explicit user approval before editing the last final-wave checkbox or marking the plan complete.
|
||||||
|
5. Wait for the user's explicit approval. Do NOT auto-continue. Do NOT call \
|
||||||
|
\`task()\` again unless the user rejects and requests fixes.
|
||||||
|
|
||||||
|
If the user rejects or requests changes:
|
||||||
|
- delegate the required fix
|
||||||
|
- re-run the affected final-wave reviewer
|
||||||
|
- present the updated results again
|
||||||
|
- wait again for explicit user approval
|
||||||
|
|
||||||
|
**DO NOT mark the final-wave checkbox complete until the user explicitly says okay.**`
|
||||||
|
}
|
||||||
|
|
||||||
export function buildStandaloneVerificationReminder(sessionId: string): string {
|
export function buildStandaloneVerificationReminder(sessionId: string): string {
|
||||||
return `
|
return `
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,86 +1,25 @@
|
|||||||
import { existsSync, readdirSync, readFileSync } from "fs"
|
import { dirname } from "path"
|
||||||
import { join, basename, dirname } from "path"
|
|
||||||
import {
|
import {
|
||||||
parseFrontmatter,
|
|
||||||
resolveCommandsInText,
|
resolveCommandsInText,
|
||||||
resolveFileReferencesInText,
|
resolveFileReferencesInText,
|
||||||
sanitizeModelField,
|
|
||||||
getClaudeConfigDir,
|
|
||||||
getOpenCodeConfigDir,
|
|
||||||
discoverPluginCommandDefinitions,
|
|
||||||
} from "../../shared"
|
} from "../../shared"
|
||||||
import { loadBuiltinCommands } from "../../features/builtin-commands"
|
|
||||||
import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types"
|
|
||||||
import { isMarkdownFile } from "../../shared/file-utils"
|
|
||||||
import { discoverAllSkills, type LoadedSkill, type LazyContentLoader } from "../../features/opencode-skill-loader"
|
import { discoverAllSkills, type LoadedSkill, type LazyContentLoader } from "../../features/opencode-skill-loader"
|
||||||
|
import { discoverCommandsSync } from "../../tools/slashcommand"
|
||||||
|
import type { CommandInfo as DiscoveredCommandInfo, CommandMetadata } from "../../tools/slashcommand/types"
|
||||||
import type { ParsedSlashCommand } from "./types"
|
import type { ParsedSlashCommand } from "./types"
|
||||||
|
|
||||||
interface CommandScope {
|
interface SkillCommandInfo {
|
||||||
type: "user" | "project" | "opencode" | "opencode-project" | "skill" | "builtin" | "plugin"
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CommandMetadata {
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
argumentHint?: string
|
|
||||||
model?: string
|
|
||||||
agent?: string
|
|
||||||
subtask?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CommandInfo {
|
|
||||||
name: string
|
name: string
|
||||||
path?: string
|
path?: string
|
||||||
metadata: CommandMetadata
|
metadata: CommandMetadata
|
||||||
content?: string
|
content?: string
|
||||||
scope: CommandScope["type"]
|
scope: "skill"
|
||||||
lazyContentLoader?: LazyContentLoader
|
lazyContentLoader?: LazyContentLoader
|
||||||
}
|
}
|
||||||
|
|
||||||
function discoverCommandsFromDir(commandsDir: string, scope: CommandScope["type"]): CommandInfo[] {
|
type CommandInfo = DiscoveredCommandInfo | SkillCommandInfo
|
||||||
if (!existsSync(commandsDir)) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries = readdirSync(commandsDir, { withFileTypes: true })
|
function skillToCommandInfo(skill: LoadedSkill): SkillCommandInfo {
|
||||||
const commands: CommandInfo[] = []
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (!isMarkdownFile(entry)) continue
|
|
||||||
|
|
||||||
const commandPath = join(commandsDir, entry.name)
|
|
||||||
const commandName = basename(entry.name, ".md")
|
|
||||||
|
|
||||||
try {
|
|
||||||
const content = readFileSync(commandPath, "utf-8")
|
|
||||||
const { data, body } = parseFrontmatter<CommandFrontmatter>(content)
|
|
||||||
|
|
||||||
const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
|
|
||||||
const metadata: CommandMetadata = {
|
|
||||||
name: commandName,
|
|
||||||
description: data.description || "",
|
|
||||||
argumentHint: data["argument-hint"],
|
|
||||||
model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"),
|
|
||||||
agent: data.agent,
|
|
||||||
subtask: Boolean(data.subtask),
|
|
||||||
}
|
|
||||||
|
|
||||||
commands.push({
|
|
||||||
name: commandName,
|
|
||||||
path: commandPath,
|
|
||||||
metadata,
|
|
||||||
content: body,
|
|
||||||
scope,
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return commands
|
|
||||||
}
|
|
||||||
|
|
||||||
function skillToCommandInfo(skill: LoadedSkill): CommandInfo {
|
|
||||||
return {
|
return {
|
||||||
name: skill.name,
|
name: skill.name,
|
||||||
path: skill.path,
|
path: skill.path,
|
||||||
@@ -104,60 +43,30 @@ export interface ExecutorOptions {
|
|||||||
enabledPluginsOverride?: Record<string, boolean>
|
enabledPluginsOverride?: Record<string, boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
function discoverPluginCommands(options?: ExecutorOptions): CommandInfo[] {
|
function filterDiscoveredCommandsByScope(
|
||||||
const pluginDefinitions = discoverPluginCommandDefinitions(options)
|
commands: DiscoveredCommandInfo[],
|
||||||
|
scope: DiscoveredCommandInfo["scope"],
|
||||||
return Object.entries(pluginDefinitions).map(([name, definition]) => ({
|
): DiscoveredCommandInfo[] {
|
||||||
name,
|
return commands.filter(command => command.scope === scope)
|
||||||
metadata: {
|
|
||||||
name,
|
|
||||||
description: definition.description || "",
|
|
||||||
model: definition.model,
|
|
||||||
agent: definition.agent,
|
|
||||||
subtask: definition.subtask,
|
|
||||||
},
|
|
||||||
content: definition.template,
|
|
||||||
scope: "plugin",
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function discoverAllCommands(options?: ExecutorOptions): Promise<CommandInfo[]> {
|
async function discoverAllCommands(options?: ExecutorOptions): Promise<CommandInfo[]> {
|
||||||
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
|
const discoveredCommands = discoverCommandsSync(process.cwd(), {
|
||||||
const userCommandsDir = join(getClaudeConfigDir(), "commands")
|
pluginsEnabled: options?.pluginsEnabled,
|
||||||
const projectCommandsDir = join(process.cwd(), ".claude", "commands")
|
enabledPluginsOverride: options?.enabledPluginsOverride,
|
||||||
const opencodeGlobalDir = join(configDir, "command")
|
})
|
||||||
const opencodeProjectDir = join(process.cwd(), ".opencode", "command")
|
|
||||||
|
|
||||||
const userCommands = discoverCommandsFromDir(userCommandsDir, "user")
|
|
||||||
const opencodeGlobalCommands = discoverCommandsFromDir(opencodeGlobalDir, "opencode")
|
|
||||||
const projectCommands = discoverCommandsFromDir(projectCommandsDir, "project")
|
|
||||||
const opencodeProjectCommands = discoverCommandsFromDir(opencodeProjectDir, "opencode-project")
|
|
||||||
const builtinCommandsMap = loadBuiltinCommands()
|
|
||||||
const builtinCommands: CommandInfo[] = Object.values(builtinCommandsMap).map(cmd => ({
|
|
||||||
name: cmd.name,
|
|
||||||
metadata: {
|
|
||||||
name: cmd.name,
|
|
||||||
description: cmd.description || "",
|
|
||||||
model: cmd.model,
|
|
||||||
agent: cmd.agent,
|
|
||||||
subtask: cmd.subtask,
|
|
||||||
},
|
|
||||||
content: cmd.template,
|
|
||||||
scope: "builtin",
|
|
||||||
}))
|
|
||||||
|
|
||||||
const skills = options?.skills ?? await discoverAllSkills()
|
const skills = options?.skills ?? await discoverAllSkills()
|
||||||
const skillCommands = skills.map(skillToCommandInfo)
|
const skillCommands = skills.map(skillToCommandInfo)
|
||||||
const pluginCommands = discoverPluginCommands(options)
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...builtinCommands,
|
...filterDiscoveredCommandsByScope(discoveredCommands, "builtin"),
|
||||||
...opencodeProjectCommands,
|
...filterDiscoveredCommandsByScope(discoveredCommands, "opencode-project"),
|
||||||
...projectCommands,
|
...filterDiscoveredCommandsByScope(discoveredCommands, "project"),
|
||||||
...opencodeGlobalCommands,
|
...filterDiscoveredCommandsByScope(discoveredCommands, "opencode"),
|
||||||
...userCommands,
|
...filterDiscoveredCommandsByScope(discoveredCommands, "user"),
|
||||||
...skillCommands,
|
...skillCommands,
|
||||||
...pluginCommands,
|
...filterDiscoveredCommandsByScope(discoveredCommands, "plugin"),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export const HOOK_NAME = "gpt-permission-continuation"
|
export const HOOK_NAME = "gpt-permission-continuation"
|
||||||
export const CONTINUATION_PROMPT = "continue"
|
export const CONTINUATION_PROMPT = "continue"
|
||||||
|
export const MAX_CONSECUTIVE_AUTO_CONTINUES = 3
|
||||||
|
|
||||||
export const DEFAULT_STALL_PATTERNS = [
|
export const DEFAULT_STALL_PATTERNS = [
|
||||||
"if you want",
|
"if you want",
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
/// <reference path="../../../bun-test.d.ts" />
|
||||||
|
|
||||||
|
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||||
|
import { describe, expect, it as test } from "bun:test"
|
||||||
|
|
||||||
import { createGptPermissionContinuationHook } from "."
|
import { createGptPermissionContinuationHook } from "."
|
||||||
|
|
||||||
@@ -15,29 +18,97 @@ type SessionMessage = {
|
|||||||
parts?: Array<{ type: string; text?: string }>
|
parts?: Array<{ type: string; text?: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMockPluginInput(messages: SessionMessage[]) {
|
type GptPermissionContext = Parameters<typeof createGptPermissionContinuationHook>[0]
|
||||||
const promptCalls: string[] = []
|
|
||||||
|
|
||||||
const ctx = {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
directory: "/tmp/test",
|
return typeof value === "object" && value !== null
|
||||||
client: {
|
}
|
||||||
session: {
|
|
||||||
messages: async () => ({ data: messages }),
|
function extractPromptText(input: unknown): string {
|
||||||
prompt: async (input: { body: { parts: Array<{ text: string }> } }) => {
|
if (!isRecord(input)) return ""
|
||||||
promptCalls.push(input.body.parts[0]?.text ?? "")
|
|
||||||
return {}
|
const body = input.body
|
||||||
},
|
if (!isRecord(body)) return ""
|
||||||
promptAsync: async (input: { body: { parts: Array<{ text: string }> } }) => {
|
|
||||||
promptCalls.push(input.body.parts[0]?.text ?? "")
|
const parts = body.parts
|
||||||
return {}
|
if (!Array.isArray(parts)) return ""
|
||||||
},
|
|
||||||
|
const firstPart = parts[0]
|
||||||
|
if (!isRecord(firstPart)) return ""
|
||||||
|
|
||||||
|
return typeof firstPart.text === "string" ? firstPart.text : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockPluginInput(messages: SessionMessage[]): {
|
||||||
|
ctx: GptPermissionContext
|
||||||
|
promptCalls: string[]
|
||||||
|
} {
|
||||||
|
const promptCalls: string[] = []
|
||||||
|
const client = createOpencodeClient({ directory: "/tmp/test" })
|
||||||
|
const shell = Object.assign(
|
||||||
|
() => {
|
||||||
|
throw new Error("$ is not used in this test")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
braces: () => [],
|
||||||
|
escape: (input: string) => input,
|
||||||
|
env() {
|
||||||
|
return shell
|
||||||
|
},
|
||||||
|
cwd() {
|
||||||
|
return shell
|
||||||
|
},
|
||||||
|
nothrow() {
|
||||||
|
return shell
|
||||||
|
},
|
||||||
|
throws() {
|
||||||
|
return shell
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as any
|
)
|
||||||
|
const request = new Request("http://localhost")
|
||||||
|
const response = new Response()
|
||||||
|
|
||||||
|
Reflect.set(client.session, "messages", async () => ({ data: messages, error: undefined, request, response }))
|
||||||
|
Reflect.set(client.session, "prompt", async (input: unknown) => {
|
||||||
|
promptCalls.push(extractPromptText(input))
|
||||||
|
return { data: undefined, error: undefined, request, response }
|
||||||
|
})
|
||||||
|
Reflect.set(client.session, "promptAsync", async (input: unknown) => {
|
||||||
|
promptCalls.push(extractPromptText(input))
|
||||||
|
return { data: undefined, error: undefined, request, response }
|
||||||
|
})
|
||||||
|
|
||||||
|
const ctx: GptPermissionContext = {
|
||||||
|
client,
|
||||||
|
project: {
|
||||||
|
id: "test-project",
|
||||||
|
worktree: "/tmp/test",
|
||||||
|
time: { created: Date.now() },
|
||||||
|
},
|
||||||
|
directory: "/tmp/test",
|
||||||
|
worktree: "/tmp/test",
|
||||||
|
serverUrl: new URL("http://localhost"),
|
||||||
|
$: shell,
|
||||||
|
}
|
||||||
|
|
||||||
return { ctx, promptCalls }
|
return { ctx, promptCalls }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createAssistantMessage(id: string, text: string): SessionMessage {
|
||||||
|
return {
|
||||||
|
info: { id, role: "assistant", modelID: "gpt-5.4" },
|
||||||
|
parts: [{ type: "text", text }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createUserMessage(id: string, text: string): SessionMessage {
|
||||||
|
return {
|
||||||
|
info: { id, role: "user" },
|
||||||
|
parts: [{ type: "text", text }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("gpt-permission-continuation", () => {
|
describe("gpt-permission-continuation", () => {
|
||||||
test("injects continue when the last GPT assistant reply asks for permission", async () => {
|
test("injects continue when the last GPT assistant reply asks for permission", async () => {
|
||||||
// given
|
// given
|
||||||
@@ -147,4 +218,117 @@ describe("gpt-permission-continuation", () => {
|
|||||||
// then
|
// then
|
||||||
expect(promptCalls).toEqual(["continue"])
|
expect(promptCalls).toEqual(["continue"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("#given repeated GPT permission tails in the same session", () => {
|
||||||
|
describe("#when the permission phrases keep changing", () => {
|
||||||
|
test("stops injecting after three consecutive auto-continues", async () => {
|
||||||
|
// given
|
||||||
|
const messages: SessionMessage[] = [
|
||||||
|
createUserMessage("msg-0", "Please continue the fix."),
|
||||||
|
createAssistantMessage("msg-1", "If you want, I can apply the patch next."),
|
||||||
|
]
|
||||||
|
const { ctx, promptCalls } = createMockPluginInput(messages)
|
||||||
|
const hook = createGptPermissionContinuationHook(ctx)
|
||||||
|
|
||||||
|
// when
|
||||||
|
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||||
|
messages.push(createUserMessage("msg-2", "continue"))
|
||||||
|
messages.push(createAssistantMessage("msg-3", "Would you like me to continue with the tests?"))
|
||||||
|
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||||
|
messages.push(createUserMessage("msg-4", "continue"))
|
||||||
|
messages.push(createAssistantMessage("msg-5", "Do you want me to wire the remaining cleanup?"))
|
||||||
|
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||||
|
messages.push(createUserMessage("msg-6", "continue"))
|
||||||
|
messages.push(createAssistantMessage("msg-7", "Shall I finish the remaining updates?"))
|
||||||
|
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(promptCalls).toEqual(["continue", "continue", "continue"])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("#when a real user message arrives between auto-continues", () => {
|
||||||
|
test("resets the consecutive auto-continue counter", async () => {
|
||||||
|
// given
|
||||||
|
const messages: SessionMessage[] = [
|
||||||
|
createUserMessage("msg-0", "Please continue the fix."),
|
||||||
|
createAssistantMessage("msg-1", "If you want, I can apply the patch next."),
|
||||||
|
]
|
||||||
|
const { ctx, promptCalls } = createMockPluginInput(messages)
|
||||||
|
const hook = createGptPermissionContinuationHook(ctx)
|
||||||
|
|
||||||
|
// when
|
||||||
|
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||||
|
messages.push(createUserMessage("msg-2", "continue"))
|
||||||
|
messages.push(createAssistantMessage("msg-3", "Would you like me to continue with the tests?"))
|
||||||
|
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||||
|
messages.push(createUserMessage("msg-4", "Please keep going and finish the cleanup."))
|
||||||
|
messages.push(createAssistantMessage("msg-5", "Do you want me to wire the remaining cleanup?"))
|
||||||
|
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||||
|
messages.push(createUserMessage("msg-6", "continue"))
|
||||||
|
messages.push(createAssistantMessage("msg-7", "Shall I finish the remaining updates?"))
|
||||||
|
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||||
|
messages.push(createUserMessage("msg-8", "continue"))
|
||||||
|
messages.push(createAssistantMessage("msg-9", "If you want, I can apply the final polish."))
|
||||||
|
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||||
|
messages.push(createUserMessage("msg-10", "continue"))
|
||||||
|
messages.push(createAssistantMessage("msg-11", "Would you like me to ship the final verification?"))
|
||||||
|
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(promptCalls).toEqual(["continue", "continue", "continue", "continue", "continue"])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("#when the same permission phrase repeats after an auto-continue", () => {
|
||||||
|
test("stops immediately on stagnation", async () => {
|
||||||
|
// given
|
||||||
|
const messages: SessionMessage[] = [
|
||||||
|
createUserMessage("msg-0", "Please continue the fix."),
|
||||||
|
createAssistantMessage("msg-1", "If you want, I can apply the patch next."),
|
||||||
|
]
|
||||||
|
const { ctx, promptCalls } = createMockPluginInput(messages)
|
||||||
|
const hook = createGptPermissionContinuationHook(ctx)
|
||||||
|
|
||||||
|
// when
|
||||||
|
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||||
|
messages.push(createUserMessage("msg-2", "continue"))
|
||||||
|
messages.push(createAssistantMessage("msg-3", "If you want, I can apply the patch next."))
|
||||||
|
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(promptCalls).toEqual(["continue"])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("#when a user manually types continue after the cap is reached", () => {
|
||||||
|
test("resets the cap and allows another auto-continue", async () => {
|
||||||
|
// given
|
||||||
|
const messages: SessionMessage[] = [
|
||||||
|
createUserMessage("msg-0", "Please continue the fix."),
|
||||||
|
createAssistantMessage("msg-1", "If you want, I can apply the patch next."),
|
||||||
|
]
|
||||||
|
const { ctx, promptCalls } = createMockPluginInput(messages)
|
||||||
|
const hook = createGptPermissionContinuationHook(ctx)
|
||||||
|
|
||||||
|
// when
|
||||||
|
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||||
|
messages.push(createUserMessage("msg-2", "continue"))
|
||||||
|
messages.push(createAssistantMessage("msg-3", "Would you like me to continue with the tests?"))
|
||||||
|
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||||
|
messages.push(createUserMessage("msg-4", "continue"))
|
||||||
|
messages.push(createAssistantMessage("msg-5", "Do you want me to wire the remaining cleanup?"))
|
||||||
|
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||||
|
messages.push(createUserMessage("msg-6", "continue"))
|
||||||
|
messages.push(createAssistantMessage("msg-7", "Shall I finish the remaining updates?"))
|
||||||
|
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||||
|
messages.push(createUserMessage("msg-8", "continue"))
|
||||||
|
messages.push(createAssistantMessage("msg-9", "If you want, I can apply the final polish."))
|
||||||
|
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(promptCalls).toEqual(["continue", "continue", "continue", "continue"])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,10 +9,16 @@ import {
|
|||||||
isGptAssistantMessage,
|
isGptAssistantMessage,
|
||||||
type SessionMessage,
|
type SessionMessage,
|
||||||
} from "./assistant-message"
|
} from "./assistant-message"
|
||||||
import { CONTINUATION_PROMPT, HOOK_NAME } from "./constants"
|
import {
|
||||||
|
CONTINUATION_PROMPT,
|
||||||
|
HOOK_NAME,
|
||||||
|
MAX_CONSECUTIVE_AUTO_CONTINUES,
|
||||||
|
} from "./constants"
|
||||||
import { detectStallPattern } from "./detector"
|
import { detectStallPattern } from "./detector"
|
||||||
import type { SessionStateStore } from "./session-state"
|
import type { SessionStateStore } from "./session-state"
|
||||||
|
|
||||||
|
type SessionState = ReturnType<SessionStateStore["getState"]>
|
||||||
|
|
||||||
async function promptContinuation(
|
async function promptContinuation(
|
||||||
ctx: PluginInput,
|
ctx: PluginInput,
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
@@ -33,6 +39,38 @@ async function promptContinuation(
|
|||||||
await ctx.client.session.prompt(payload)
|
await ctx.client.session.prompt(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLastUserMessageBefore(
|
||||||
|
messages: SessionMessage[],
|
||||||
|
lastAssistantIndex: number,
|
||||||
|
): SessionMessage | null {
|
||||||
|
for (let index = lastAssistantIndex - 1; index >= 0; index--) {
|
||||||
|
if (messages[index].info?.role === "user") {
|
||||||
|
return messages[index]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAutoContinuationUserMessage(message: SessionMessage): boolean {
|
||||||
|
return extractAssistantText(message).trim().toLowerCase() === CONTINUATION_PROMPT
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPermissionPhrase(text: string): string | null {
|
||||||
|
const tail = text.slice(-800)
|
||||||
|
const lines = tail.split("\n").map((line) => line.trim()).filter(Boolean)
|
||||||
|
const hotZone = lines.slice(-3).join(" ")
|
||||||
|
const sentenceParts = hotZone.trim().replace(/\s+/g, " ").split(/(?<=[.!?])\s+/)
|
||||||
|
const trailingSegment = sentenceParts[sentenceParts.length - 1]?.trim().toLowerCase() ?? ""
|
||||||
|
return trailingSegment || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAutoContinuationState(state: SessionState): void {
|
||||||
|
state.consecutiveAutoContinueCount = 0
|
||||||
|
state.awaitingAutoContinuationResponse = false
|
||||||
|
state.lastAutoContinuePermissionPhrase = undefined
|
||||||
|
}
|
||||||
|
|
||||||
export function createGptPermissionContinuationHandler(args: {
|
export function createGptPermissionContinuationHandler(args: {
|
||||||
ctx: PluginInput
|
ctx: PluginInput
|
||||||
sessionStateStore: SessionStateStore
|
sessionStateStore: SessionStateStore
|
||||||
@@ -78,6 +116,21 @@ export function createGptPermissionContinuationHandler(args: {
|
|||||||
const lastAssistantMessage = getLastAssistantMessage(messages)
|
const lastAssistantMessage = getLastAssistantMessage(messages)
|
||||||
if (!lastAssistantMessage) return
|
if (!lastAssistantMessage) return
|
||||||
|
|
||||||
|
const lastAssistantIndex = messages.lastIndexOf(lastAssistantMessage)
|
||||||
|
const previousUserMessage = getLastUserMessageBefore(messages, lastAssistantIndex)
|
||||||
|
const previousUserMessageWasAutoContinuation =
|
||||||
|
previousUserMessage !== null
|
||||||
|
&& state.awaitingAutoContinuationResponse
|
||||||
|
&& isAutoContinuationUserMessage(previousUserMessage)
|
||||||
|
|
||||||
|
if (previousUserMessageWasAutoContinuation) {
|
||||||
|
state.awaitingAutoContinuationResponse = false
|
||||||
|
} else if (previousUserMessage) {
|
||||||
|
resetAutoContinuationState(state)
|
||||||
|
} else {
|
||||||
|
state.awaitingAutoContinuationResponse = false
|
||||||
|
}
|
||||||
|
|
||||||
const messageID = lastAssistantMessage.info?.id
|
const messageID = lastAssistantMessage.info?.id
|
||||||
if (messageID && state.lastHandledMessageID === messageID) {
|
if (messageID && state.lastHandledMessageID === messageID) {
|
||||||
log(`[${HOOK_NAME}] Skipped: already handled assistant message`, { sessionID, messageID })
|
log(`[${HOOK_NAME}] Skipped: already handled assistant message`, { sessionID, messageID })
|
||||||
@@ -99,9 +152,40 @@ export function createGptPermissionContinuationHandler(args: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const permissionPhrase = extractPermissionPhrase(assistantText)
|
||||||
|
if (!permissionPhrase) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.consecutiveAutoContinueCount >= MAX_CONSECUTIVE_AUTO_CONTINUES) {
|
||||||
|
state.lastHandledMessageID = messageID
|
||||||
|
log(`[${HOOK_NAME}] Skipped: reached max consecutive auto-continues`, {
|
||||||
|
sessionID,
|
||||||
|
messageID,
|
||||||
|
consecutiveAutoContinueCount: state.consecutiveAutoContinueCount,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
state.consecutiveAutoContinueCount >= 1
|
||||||
|
&& state.lastAutoContinuePermissionPhrase === permissionPhrase
|
||||||
|
) {
|
||||||
|
state.lastHandledMessageID = messageID
|
||||||
|
log(`[${HOOK_NAME}] Skipped: repeated permission phrase after auto-continue`, {
|
||||||
|
sessionID,
|
||||||
|
messageID,
|
||||||
|
permissionPhrase,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
state.inFlight = true
|
state.inFlight = true
|
||||||
await promptContinuation(ctx, sessionID)
|
await promptContinuation(ctx, sessionID)
|
||||||
state.lastHandledMessageID = messageID
|
state.lastHandledMessageID = messageID
|
||||||
|
state.consecutiveAutoContinueCount += 1
|
||||||
|
state.awaitingAutoContinuationResponse = true
|
||||||
|
state.lastAutoContinuePermissionPhrase = permissionPhrase
|
||||||
state.lastInjectedAt = Date.now()
|
state.lastInjectedAt = Date.now()
|
||||||
log(`[${HOOK_NAME}] Injected continuation prompt`, { sessionID, messageID })
|
log(`[${HOOK_NAME}] Injected continuation prompt`, { sessionID, messageID })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
type SessionState = {
|
type SessionState = {
|
||||||
inFlight: boolean
|
inFlight: boolean
|
||||||
|
consecutiveAutoContinueCount: number
|
||||||
|
awaitingAutoContinuationResponse: boolean
|
||||||
lastHandledMessageID?: string
|
lastHandledMessageID?: string
|
||||||
|
lastAutoContinuePermissionPhrase?: string
|
||||||
lastInjectedAt?: number
|
lastInjectedAt?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,6 +18,8 @@ export function createSessionStateStore() {
|
|||||||
|
|
||||||
const created: SessionState = {
|
const created: SessionState = {
|
||||||
inFlight: false,
|
inFlight: false,
|
||||||
|
consecutiveAutoContinueCount: 0,
|
||||||
|
awaitingAutoContinuationResponse: false,
|
||||||
}
|
}
|
||||||
states.set(sessionID, created)
|
states.set(sessionID, created)
|
||||||
return created
|
return created
|
||||||
|
|||||||
@@ -134,8 +134,8 @@ describe("model fallback hook", () => {
|
|||||||
|
|
||||||
//#then - chain should progress to entry[1], not repeat entry[0]
|
//#then - chain should progress to entry[1], not repeat entry[0]
|
||||||
expect(secondOutput.message["model"]).toEqual({
|
expect(secondOutput.message["model"]).toEqual({
|
||||||
providerID: "kimi-for-coding",
|
providerID: "opencode-go",
|
||||||
modelID: "k2p5",
|
modelID: "kimi-k2.5",
|
||||||
})
|
})
|
||||||
expect(secondOutput.message["variant"]).toBeUndefined()
|
expect(secondOutput.message["variant"]).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
|
import { describe, expect, it, mock } from "bun:test"
|
||||||
|
|
||||||
|
import { OhMyOpenCodeConfigSchema } from "../config"
|
||||||
|
|
||||||
|
const { createPreemptiveCompactionHook } = await import("./preemptive-compaction")
|
||||||
|
|
||||||
|
type HookContext = Parameters<typeof createPreemptiveCompactionHook>[0]
|
||||||
|
|
||||||
|
function createMockContext(): HookContext {
|
||||||
|
return {
|
||||||
|
client: {
|
||||||
|
session: {
|
||||||
|
messages: mock(() => Promise.resolve({ data: [] })),
|
||||||
|
summarize: mock(() => Promise.resolve({})),
|
||||||
|
},
|
||||||
|
tui: {
|
||||||
|
showToast: mock(() => Promise.resolve()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
directory: "/tmp/test",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("preemptive-compaction aws-bedrock-anthropic", () => {
|
||||||
|
it("triggers compaction for aws-bedrock-anthropic provider when usage exceeds threshold", async () => {
|
||||||
|
// given
|
||||||
|
const ctx = createMockContext()
|
||||||
|
const pluginConfig = OhMyOpenCodeConfigSchema.parse({})
|
||||||
|
const hook = createPreemptiveCompactionHook(ctx, pluginConfig)
|
||||||
|
const sessionID = "ses_aws_bedrock_anthropic_high"
|
||||||
|
|
||||||
|
await hook.event({
|
||||||
|
event: {
|
||||||
|
type: "message.updated",
|
||||||
|
properties: {
|
||||||
|
info: {
|
||||||
|
role: "assistant",
|
||||||
|
sessionID,
|
||||||
|
providerID: "aws-bedrock-anthropic",
|
||||||
|
modelID: "claude-sonnet-4-6",
|
||||||
|
finish: true,
|
||||||
|
tokens: {
|
||||||
|
input: 170000,
|
||||||
|
output: 1000,
|
||||||
|
reasoning: 0,
|
||||||
|
cache: { read: 10000, write: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// when
|
||||||
|
await hook["tool.execute.after"](
|
||||||
|
{ tool: "bash", sessionID, callID: "call_aws_bedrock_1" },
|
||||||
|
{ title: "", output: "test", metadata: null },
|
||||||
|
)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(ctx.client.session.summarize).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,23 +1,13 @@
|
|||||||
import { log } from "../shared/logger"
|
import { log } from "../shared/logger"
|
||||||
import type { OhMyOpenCodeConfig } from "../config"
|
import type { OhMyOpenCodeConfig } from "../config"
|
||||||
|
import {
|
||||||
|
resolveActualContextLimit,
|
||||||
|
type ContextLimitModelCacheState,
|
||||||
|
} from "../shared/context-limit-resolver"
|
||||||
|
|
||||||
import { resolveCompactionModel } from "./shared/compaction-model-resolver"
|
import { resolveCompactionModel } from "./shared/compaction-model-resolver"
|
||||||
const DEFAULT_ACTUAL_LIMIT = 200_000
|
|
||||||
const PREEMPTIVE_COMPACTION_TIMEOUT_MS = 120_000
|
const PREEMPTIVE_COMPACTION_TIMEOUT_MS = 120_000
|
||||||
|
|
||||||
type ModelCacheStateLike = {
|
|
||||||
anthropicContext1MEnabled: boolean
|
|
||||||
modelContextLimitsCache?: Map<string, number>
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAnthropicActualLimit(modelCacheState?: ModelCacheStateLike): number {
|
|
||||||
return (modelCacheState?.anthropicContext1MEnabled ?? false) ||
|
|
||||||
process.env.ANTHROPIC_1M_CONTEXT === "true" ||
|
|
||||||
process.env.VERTEX_ANTHROPIC_1M_CONTEXT === "true"
|
|
||||||
? 1_000_000
|
|
||||||
: DEFAULT_ACTUAL_LIMIT
|
|
||||||
}
|
|
||||||
|
|
||||||
const PREEMPTIVE_COMPACTION_THRESHOLD = 0.78
|
const PREEMPTIVE_COMPACTION_THRESHOLD = 0.78
|
||||||
|
|
||||||
interface TokenInfo {
|
interface TokenInfo {
|
||||||
@@ -33,7 +23,7 @@ interface CachedCompactionState {
|
|||||||
tokens: TokenInfo
|
tokens: TokenInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
function withTimeout<TValue>(
|
async function withTimeout<TValue>(
|
||||||
promise: Promise<TValue>,
|
promise: Promise<TValue>,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
errorMessage: string,
|
errorMessage: string,
|
||||||
@@ -46,17 +36,13 @@ function withTimeout<TValue>(
|
|||||||
}, timeoutMs)
|
}, timeoutMs)
|
||||||
})
|
})
|
||||||
|
|
||||||
return Promise.race([promise, timeoutPromise]).finally(() => {
|
return await Promise.race([promise, timeoutPromise]).finally(() => {
|
||||||
if (timeoutID !== undefined) {
|
if (timeoutID !== undefined) {
|
||||||
clearTimeout(timeoutID)
|
clearTimeout(timeoutID)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAnthropicProvider(providerID: string): boolean {
|
|
||||||
return providerID === "anthropic" || providerID === "google-vertex-anthropic"
|
|
||||||
}
|
|
||||||
|
|
||||||
type PluginInput = {
|
type PluginInput = {
|
||||||
client: {
|
client: {
|
||||||
session: {
|
session: {
|
||||||
@@ -76,7 +62,7 @@ type PluginInput = {
|
|||||||
export function createPreemptiveCompactionHook(
|
export function createPreemptiveCompactionHook(
|
||||||
ctx: PluginInput,
|
ctx: PluginInput,
|
||||||
pluginConfig: OhMyOpenCodeConfig,
|
pluginConfig: OhMyOpenCodeConfig,
|
||||||
modelCacheState?: ModelCacheStateLike,
|
modelCacheState?: ContextLimitModelCacheState,
|
||||||
) {
|
) {
|
||||||
const compactionInProgress = new Set<string>()
|
const compactionInProgress = new Set<string>()
|
||||||
const compactedSessions = new Set<string>()
|
const compactedSessions = new Set<string>()
|
||||||
@@ -92,24 +78,18 @@ export function createPreemptiveCompactionHook(
|
|||||||
const cached = tokenCache.get(sessionID)
|
const cached = tokenCache.get(sessionID)
|
||||||
if (!cached) return
|
if (!cached) return
|
||||||
|
|
||||||
const isAnthropic = isAnthropicProvider(cached.providerID)
|
const actualLimit = resolveActualContextLimit(
|
||||||
const modelSpecificLimit = !isAnthropic
|
cached.providerID,
|
||||||
? modelCacheState?.modelContextLimitsCache?.get(`${cached.providerID}/${cached.modelID}`)
|
cached.modelID,
|
||||||
: undefined
|
modelCacheState,
|
||||||
|
)
|
||||||
|
|
||||||
let actualLimit: number
|
if (actualLimit === null) {
|
||||||
if (isAnthropic) {
|
log("[preemptive-compaction] Skipping preemptive compaction: unknown context limit for model", {
|
||||||
actualLimit = getAnthropicActualLimit(modelCacheState)
|
providerID: cached.providerID,
|
||||||
} else {
|
modelID: cached.modelID,
|
||||||
if (modelSpecificLimit === undefined) {
|
})
|
||||||
log("[preemptive-compaction] Skipping preemptive compaction: unknown context limit for model", {
|
return
|
||||||
providerID: cached.providerID,
|
|
||||||
modelID: cached.modelID,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
actualLimit = modelSpecificLimit
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastTokens = cached.tokens
|
const lastTokens = cached.tokens
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, expect, it } from "bun:test"
|
||||||
|
import type { HookDeps, RuntimeFallbackPluginInput } from "./types"
|
||||||
|
import type { AutoRetryHelpers } from "./auto-retry"
|
||||||
|
import { createFallbackState } from "./fallback-state"
|
||||||
|
import { createEventHandler } from "./event-handler"
|
||||||
|
|
||||||
|
function createContext(): RuntimeFallbackPluginInput {
|
||||||
|
return {
|
||||||
|
client: {
|
||||||
|
session: {
|
||||||
|
abort: async () => ({}),
|
||||||
|
messages: async () => ({ data: [] }),
|
||||||
|
promptAsync: async () => ({}),
|
||||||
|
},
|
||||||
|
tui: {
|
||||||
|
showToast: async () => ({}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
directory: "/test/dir",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDeps(): HookDeps {
|
||||||
|
return {
|
||||||
|
ctx: createContext(),
|
||||||
|
config: {
|
||||||
|
enabled: true,
|
||||||
|
retry_on_errors: [429, 503, 529],
|
||||||
|
max_fallback_attempts: 3,
|
||||||
|
cooldown_seconds: 60,
|
||||||
|
timeout_seconds: 30,
|
||||||
|
notify_on_fallback: false,
|
||||||
|
},
|
||||||
|
options: undefined,
|
||||||
|
pluginConfig: {},
|
||||||
|
sessionStates: new Map(),
|
||||||
|
sessionLastAccess: new Map(),
|
||||||
|
sessionRetryInFlight: new Set(),
|
||||||
|
sessionAwaitingFallbackResult: new Set(),
|
||||||
|
sessionFallbackTimeouts: new Map(),
|
||||||
|
sessionStatusRetryKeys: new Map(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHelpers(deps: HookDeps, abortCalls: string[], clearCalls: string[]): AutoRetryHelpers {
|
||||||
|
return {
|
||||||
|
abortSessionRequest: async (sessionID: string) => {
|
||||||
|
abortCalls.push(sessionID)
|
||||||
|
},
|
||||||
|
clearSessionFallbackTimeout: (sessionID: string) => {
|
||||||
|
clearCalls.push(sessionID)
|
||||||
|
deps.sessionFallbackTimeouts.delete(sessionID)
|
||||||
|
},
|
||||||
|
scheduleSessionFallbackTimeout: () => {},
|
||||||
|
autoRetryWithFallback: async () => {},
|
||||||
|
resolveAgentForSessionFromContext: async () => undefined,
|
||||||
|
cleanupStaleSessions: () => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createEventHandler", () => {
|
||||||
|
it("#given a session retry dedupe key #when session.stop fires #then the retry dedupe key is cleared", async () => {
|
||||||
|
// given
|
||||||
|
const sessionID = "session-stop"
|
||||||
|
const deps = createDeps()
|
||||||
|
const abortCalls: string[] = []
|
||||||
|
const clearCalls: string[] = []
|
||||||
|
const state = createFallbackState("google/gemini-2.5-pro")
|
||||||
|
state.pendingFallbackModel = "openai/gpt-5.4"
|
||||||
|
deps.sessionStates.set(sessionID, state)
|
||||||
|
deps.sessionRetryInFlight.add(sessionID)
|
||||||
|
deps.sessionStatusRetryKeys.set(sessionID, "retry:1")
|
||||||
|
const handler = createEventHandler(deps, createHelpers(deps, abortCalls, clearCalls))
|
||||||
|
|
||||||
|
// when
|
||||||
|
await handler({ event: { type: "session.stop", properties: { sessionID } } })
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(deps.sessionStatusRetryKeys.has(sessionID)).toBe(false)
|
||||||
|
expect(clearCalls).toEqual([sessionID])
|
||||||
|
expect(abortCalls).toEqual([sessionID])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("#given a session retry dedupe key without a pending fallback result #when session.idle fires #then the retry dedupe key is cleared", async () => {
|
||||||
|
// given
|
||||||
|
const sessionID = "session-idle"
|
||||||
|
const deps = createDeps()
|
||||||
|
const abortCalls: string[] = []
|
||||||
|
const clearCalls: string[] = []
|
||||||
|
const state = createFallbackState("google/gemini-2.5-pro")
|
||||||
|
state.pendingFallbackModel = "openai/gpt-5.4"
|
||||||
|
deps.sessionStates.set(sessionID, state)
|
||||||
|
deps.sessionRetryInFlight.add(sessionID)
|
||||||
|
deps.sessionFallbackTimeouts.set(sessionID, 1)
|
||||||
|
deps.sessionStatusRetryKeys.set(sessionID, "retry:1")
|
||||||
|
const handler = createEventHandler(deps, createHelpers(deps, abortCalls, clearCalls))
|
||||||
|
|
||||||
|
// when
|
||||||
|
await handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(deps.sessionStatusRetryKeys.has(sessionID)).toBe(false)
|
||||||
|
expect(clearCalls).toEqual([sessionID])
|
||||||
|
expect(abortCalls).toEqual([])
|
||||||
|
expect(state.pendingFallbackModel).toBe(undefined)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -54,6 +54,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
|||||||
|
|
||||||
sessionRetryInFlight.delete(sessionID)
|
sessionRetryInFlight.delete(sessionID)
|
||||||
sessionAwaitingFallbackResult.delete(sessionID)
|
sessionAwaitingFallbackResult.delete(sessionID)
|
||||||
|
sessionStatusRetryKeys.delete(sessionID)
|
||||||
|
|
||||||
const state = sessionStates.get(sessionID)
|
const state = sessionStates.get(sessionID)
|
||||||
if (state?.pendingFallbackModel) {
|
if (state?.pendingFallbackModel) {
|
||||||
@@ -75,6 +76,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
|||||||
const hadTimeout = sessionFallbackTimeouts.has(sessionID)
|
const hadTimeout = sessionFallbackTimeouts.has(sessionID)
|
||||||
helpers.clearSessionFallbackTimeout(sessionID)
|
helpers.clearSessionFallbackTimeout(sessionID)
|
||||||
sessionRetryInFlight.delete(sessionID)
|
sessionRetryInFlight.delete(sessionID)
|
||||||
|
sessionStatusRetryKeys.delete(sessionID)
|
||||||
|
|
||||||
const state = sessionStates.get(sessionID)
|
const state = sessionStates.get(sessionID)
|
||||||
if (state?.pendingFallbackModel) {
|
if (state?.pendingFallbackModel) {
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, expect, it } from "bun:test"
|
||||||
|
import type { RuntimeFallbackPluginInput } from "./types"
|
||||||
|
import { createRuntimeFallbackHook } from "./hook"
|
||||||
|
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||||
|
|
||||||
|
function createContext(promptCalls: unknown[]): RuntimeFallbackPluginInput {
|
||||||
|
return {
|
||||||
|
client: {
|
||||||
|
session: {
|
||||||
|
abort: async () => ({}),
|
||||||
|
messages: async () => ({
|
||||||
|
data: [{ info: { role: "user" }, parts: [{ type: "text", text: "retry this" }] }],
|
||||||
|
}),
|
||||||
|
promptAsync: async (args: unknown) => {
|
||||||
|
promptCalls.push(args)
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tui: {
|
||||||
|
showToast: async () => ({}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
directory: "/test/dir",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createRuntimeFallbackHook dispose retry-key cleanup", () => {
|
||||||
|
it("#given a session.status retry key #when dispose() is called #then the same retry event is not deduplicated afterward", async () => {
|
||||||
|
// given
|
||||||
|
const promptCalls: unknown[] = []
|
||||||
|
const sessionID = "session-dispose-retry-key"
|
||||||
|
const hook = createRuntimeFallbackHook(createContext(promptCalls), {
|
||||||
|
config: {
|
||||||
|
enabled: true,
|
||||||
|
retry_on_errors: [429, 503, 529],
|
||||||
|
max_fallback_attempts: 3,
|
||||||
|
cooldown_seconds: 60,
|
||||||
|
timeout_seconds: 30,
|
||||||
|
notify_on_fallback: false,
|
||||||
|
},
|
||||||
|
pluginConfig: {
|
||||||
|
categories: {
|
||||||
|
test: {
|
||||||
|
fallback_models: ["openai/gpt-5.2"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
SessionCategoryRegistry.register(sessionID, "test")
|
||||||
|
|
||||||
|
await hook.event({
|
||||||
|
event: {
|
||||||
|
type: "session.created",
|
||||||
|
properties: { info: { id: sessionID, model: "quotio/claude-opus-4-6" } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const retryEvent = {
|
||||||
|
event: {
|
||||||
|
type: "session.status",
|
||||||
|
properties: {
|
||||||
|
sessionID,
|
||||||
|
status: {
|
||||||
|
type: "retry",
|
||||||
|
attempt: 1,
|
||||||
|
message: "All credentials for model claude-opus-4-6 are cooling down [retrying in 7m 56s attempt #1]",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await hook.event(retryEvent)
|
||||||
|
expect(promptCalls).toHaveLength(1)
|
||||||
|
|
||||||
|
// when
|
||||||
|
hook.dispose?.()
|
||||||
|
await hook.event({
|
||||||
|
event: {
|
||||||
|
type: "session.created",
|
||||||
|
properties: { info: { id: sessionID, model: "quotio/claude-opus-4-6" } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await hook.event(retryEvent)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(promptCalls).toHaveLength(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -76,6 +76,7 @@ export function createRuntimeFallbackHook(
|
|||||||
deps.sessionRetryInFlight.clear()
|
deps.sessionRetryInFlight.clear()
|
||||||
deps.sessionAwaitingFallbackResult.clear()
|
deps.sessionAwaitingFallbackResult.clear()
|
||||||
deps.sessionFallbackTimeouts.clear()
|
deps.sessionFallbackTimeouts.clear()
|
||||||
|
deps.sessionStatusRetryKeys.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, it } from "bun:test"
|
||||||
|
import type { RuntimeFallbackPluginInput } from "./types"
|
||||||
|
import { hasVisibleAssistantResponse } from "./visible-assistant-response"
|
||||||
|
|
||||||
|
function createContext(messagesResponse: unknown): RuntimeFallbackPluginInput {
|
||||||
|
return {
|
||||||
|
client: {
|
||||||
|
session: {
|
||||||
|
abort: async () => ({}),
|
||||||
|
messages: async () => messagesResponse,
|
||||||
|
promptAsync: async () => ({}),
|
||||||
|
},
|
||||||
|
tui: {
|
||||||
|
showToast: async () => ({}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
directory: "/test/dir",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("hasVisibleAssistantResponse", () => {
|
||||||
|
it("#given only an old assistant reply before the latest user turn #when visibility is checked #then the stale reply is ignored", async () => {
|
||||||
|
// given
|
||||||
|
const checkVisibleResponse = hasVisibleAssistantResponse(() => undefined)
|
||||||
|
const ctx = createContext({
|
||||||
|
data: [
|
||||||
|
{ info: { role: "user" }, parts: [{ type: "text", text: "older question" }] },
|
||||||
|
{ info: { role: "assistant" }, parts: [{ type: "text", text: "older answer" }] },
|
||||||
|
{ info: { role: "user" }, parts: [{ type: "text", text: "latest question" }] },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await checkVisibleResponse(ctx, "session-old-assistant", undefined)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("#given an assistant reply after the latest user turn #when visibility is checked #then the current reply is treated as visible", async () => {
|
||||||
|
// given
|
||||||
|
const checkVisibleResponse = hasVisibleAssistantResponse(() => undefined)
|
||||||
|
const ctx = createContext({
|
||||||
|
data: [
|
||||||
|
{ info: { role: "user" }, parts: [{ type: "text", text: "latest question" }] },
|
||||||
|
{ info: { role: "assistant" }, parts: [{ type: "text", text: "visible answer" }] },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await checkVisibleResponse(ctx, "session-visible-assistant", undefined)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -7,49 +7,12 @@ import { createFallbackState } from "./fallback-state"
|
|||||||
import { getFallbackModelsForSession } from "./fallback-models"
|
import { getFallbackModelsForSession } from "./fallback-models"
|
||||||
import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model"
|
import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model"
|
||||||
import { dispatchFallbackRetry } from "./fallback-retry-dispatcher"
|
import { dispatchFallbackRetry } from "./fallback-retry-dispatcher"
|
||||||
import { extractSessionMessages } from "./session-messages"
|
import { hasVisibleAssistantResponse } from "./visible-assistant-response"
|
||||||
|
|
||||||
export function hasVisibleAssistantResponse(extractAutoRetrySignalFn: typeof extractAutoRetrySignal) {
|
export { hasVisibleAssistantResponse } from "./visible-assistant-response"
|
||||||
return async (
|
|
||||||
ctx: HookDeps["ctx"],
|
|
||||||
sessionID: string,
|
|
||||||
_info: Record<string, unknown> | undefined,
|
|
||||||
): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const messagesResp = await ctx.client.session.messages({
|
|
||||||
path: { id: sessionID },
|
|
||||||
query: { directory: ctx.directory },
|
|
||||||
})
|
|
||||||
|
|
||||||
const msgs = extractSessionMessages(messagesResp)
|
|
||||||
|
|
||||||
if (!msgs || msgs.length === 0) return false
|
|
||||||
|
|
||||||
const lastAssistant = [...msgs].reverse().find((m) => m.info?.role === "assistant")
|
|
||||||
if (!lastAssistant) return false
|
|
||||||
if (lastAssistant.info?.error) return false
|
|
||||||
|
|
||||||
const parts = lastAssistant.parts ??
|
|
||||||
(lastAssistant.info?.parts as Array<{ type?: string; text?: string }> | undefined)
|
|
||||||
|
|
||||||
const textFromParts = (parts ?? [])
|
|
||||||
.filter((p) => p.type === "text" && typeof p.text === "string")
|
|
||||||
.map((p) => p.text!.trim())
|
|
||||||
.filter((text) => text.length > 0)
|
|
||||||
.join("\n")
|
|
||||||
|
|
||||||
if (!textFromParts) return false
|
|
||||||
if (extractAutoRetrySignalFn({ message: textFromParts })) return false
|
|
||||||
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createMessageUpdateHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
export function createMessageUpdateHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
||||||
const { ctx, config, pluginConfig, sessionStates, sessionLastAccess, sessionRetryInFlight, sessionAwaitingFallbackResult } = deps
|
const { ctx, config, pluginConfig, sessionStates, sessionLastAccess, sessionRetryInFlight, sessionAwaitingFallbackResult, sessionStatusRetryKeys } = deps
|
||||||
const checkVisibleResponse = hasVisibleAssistantResponse(extractAutoRetrySignal)
|
const checkVisibleResponse = hasVisibleAssistantResponse(extractAutoRetrySignal)
|
||||||
|
|
||||||
return async (props: Record<string, unknown> | undefined) => {
|
return async (props: Record<string, unknown> | undefined) => {
|
||||||
@@ -91,6 +54,7 @@ export function createMessageUpdateHandler(deps: HookDeps, helpers: AutoRetryHel
|
|||||||
}
|
}
|
||||||
|
|
||||||
sessionAwaitingFallbackResult.delete(sessionID)
|
sessionAwaitingFallbackResult.delete(sessionID)
|
||||||
|
sessionStatusRetryKeys.delete(sessionID)
|
||||||
helpers.clearSessionFallbackTimeout(sessionID)
|
helpers.clearSessionFallbackTimeout(sessionID)
|
||||||
const state = sessionStates.get(sessionID)
|
const state = sessionStates.get(sessionID)
|
||||||
if (state?.pendingFallbackModel) {
|
if (state?.pendingFallbackModel) {
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { describe, expect, it } from "bun:test"
|
||||||
|
import type { HookDeps, RuntimeFallbackPluginInput } from "./types"
|
||||||
|
import type { AutoRetryHelpers } from "./auto-retry"
|
||||||
|
import { createFallbackState } from "./fallback-state"
|
||||||
|
|
||||||
|
type MessageUpdateHandlerModule = typeof import("./message-update-handler")
|
||||||
|
|
||||||
|
async function importFreshMessageUpdateHandlerModule(): Promise<MessageUpdateHandlerModule> {
|
||||||
|
return import(`./message-update-handler?success-retry-key-${Date.now()}-${Math.random()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createContext(messagesResponse: unknown): RuntimeFallbackPluginInput {
|
||||||
|
return {
|
||||||
|
client: {
|
||||||
|
session: {
|
||||||
|
abort: async () => ({}),
|
||||||
|
messages: async () => messagesResponse,
|
||||||
|
promptAsync: async () => ({}),
|
||||||
|
},
|
||||||
|
tui: {
|
||||||
|
showToast: async () => ({}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
directory: "/test/dir",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDeps(messagesResponse: unknown): HookDeps {
|
||||||
|
return {
|
||||||
|
ctx: createContext(messagesResponse),
|
||||||
|
config: {
|
||||||
|
enabled: true,
|
||||||
|
retry_on_errors: [429, 503, 529],
|
||||||
|
max_fallback_attempts: 3,
|
||||||
|
cooldown_seconds: 60,
|
||||||
|
timeout_seconds: 30,
|
||||||
|
notify_on_fallback: false,
|
||||||
|
},
|
||||||
|
options: undefined,
|
||||||
|
pluginConfig: {},
|
||||||
|
sessionStates: new Map(),
|
||||||
|
sessionLastAccess: new Map(),
|
||||||
|
sessionRetryInFlight: new Set(),
|
||||||
|
sessionAwaitingFallbackResult: new Set(),
|
||||||
|
sessionFallbackTimeouts: new Map(),
|
||||||
|
sessionStatusRetryKeys: new Map(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHelpers(clearCalls: string[]): AutoRetryHelpers {
|
||||||
|
return {
|
||||||
|
abortSessionRequest: async () => {},
|
||||||
|
clearSessionFallbackTimeout: (sessionID: string) => {
|
||||||
|
clearCalls.push(sessionID)
|
||||||
|
},
|
||||||
|
scheduleSessionFallbackTimeout: () => {},
|
||||||
|
autoRetryWithFallback: async () => {},
|
||||||
|
resolveAgentForSessionFromContext: async () => undefined,
|
||||||
|
cleanupStaleSessions: () => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createMessageUpdateHandler retry-key cleanup", () => {
|
||||||
|
it("#given a visible assistant reply after the latest user turn #when a non-error assistant update arrives #then the retry dedupe key is cleared with the fallback watchdog", async () => {
|
||||||
|
// given
|
||||||
|
const { createMessageUpdateHandler } = await importFreshMessageUpdateHandlerModule()
|
||||||
|
const sessionID = "session-visible-assistant"
|
||||||
|
const clearCalls: string[] = []
|
||||||
|
const deps = createDeps({
|
||||||
|
data: [
|
||||||
|
{ info: { role: "user" }, parts: [{ type: "text", text: "latest question" }] },
|
||||||
|
{ info: { role: "assistant" }, parts: [{ type: "text", text: "visible answer" }] },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const state = createFallbackState("google/gemini-2.5-pro")
|
||||||
|
state.pendingFallbackModel = "openai/gpt-5.4"
|
||||||
|
deps.sessionStates.set(sessionID, state)
|
||||||
|
deps.sessionAwaitingFallbackResult.add(sessionID)
|
||||||
|
deps.sessionStatusRetryKeys.set(sessionID, "retry:1")
|
||||||
|
const handler = createMessageUpdateHandler(deps, createHelpers(clearCalls))
|
||||||
|
|
||||||
|
// when
|
||||||
|
await handler({
|
||||||
|
info: {
|
||||||
|
sessionID,
|
||||||
|
role: "assistant",
|
||||||
|
model: "openai/gpt-5.4",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(deps.sessionAwaitingFallbackResult.has(sessionID)).toBe(false)
|
||||||
|
expect(deps.sessionStatusRetryKeys.has(sessionID)).toBe(false)
|
||||||
|
expect(state.pendingFallbackModel).toBe(undefined)
|
||||||
|
expect(clearCalls).toEqual([sessionID])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { HookDeps } from "./types"
|
||||||
|
import type { SessionMessage, SessionMessagePart } from "./session-messages"
|
||||||
|
import { extractSessionMessages } from "./session-messages"
|
||||||
|
import { extractAutoRetrySignal } from "./error-classifier"
|
||||||
|
|
||||||
|
function getLastUserMessageIndex(messages: SessionMessage[]): number {
|
||||||
|
for (let index = messages.length - 1; index >= 0; index--) {
|
||||||
|
if (messages[index]?.info?.role === "user") {
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAssistantText(parts: SessionMessagePart[] | undefined): string {
|
||||||
|
return (parts ?? [])
|
||||||
|
.flatMap((part) => {
|
||||||
|
if (part.type !== "text") {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = typeof part.text === "string" ? part.text.trim() : ""
|
||||||
|
return text.length > 0 ? [text] : []
|
||||||
|
})
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasVisibleAssistantResponse(extractAutoRetrySignalFn: typeof extractAutoRetrySignal) {
|
||||||
|
return async (
|
||||||
|
ctx: HookDeps["ctx"],
|
||||||
|
sessionID: string,
|
||||||
|
_info: Record<string, unknown> | undefined,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const messagesResponse = await ctx.client.session.messages({
|
||||||
|
path: { id: sessionID },
|
||||||
|
query: { directory: ctx.directory },
|
||||||
|
})
|
||||||
|
const messages = extractSessionMessages(messagesResponse)
|
||||||
|
if (!messages || messages.length === 0) return false
|
||||||
|
|
||||||
|
const lastUserMessageIndex = getLastUserMessageIndex(messages)
|
||||||
|
if (lastUserMessageIndex === -1) return false
|
||||||
|
|
||||||
|
for (let index = lastUserMessageIndex + 1; index < messages.length; index++) {
|
||||||
|
const message = messages[index]
|
||||||
|
if (message?.info?.role !== "assistant") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.info?.error) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const infoParts = message.info?.parts
|
||||||
|
const infoMessageParts = Array.isArray(infoParts)
|
||||||
|
? infoParts.filter((part): part is SessionMessagePart => typeof part === "object" && part !== null)
|
||||||
|
: undefined
|
||||||
|
const parts = message.parts && message.parts.length > 0
|
||||||
|
? message.parts
|
||||||
|
: infoMessageParts
|
||||||
|
const assistantText = getAssistantText(parts)
|
||||||
|
if (!assistantText) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extractAutoRetrySignalFn({ message: assistantText })) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
|
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||||
|
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"
|
||||||
|
import * as agents from "../agents"
|
||||||
|
import * as shared from "../shared"
|
||||||
|
import * as sisyphusJunior from "../agents/sisyphus-junior"
|
||||||
|
import type { OhMyOpenCodeConfig } from "../config"
|
||||||
|
import * as agentLoader from "../features/claude-code-agent-loader"
|
||||||
|
import * as skillLoader from "../features/opencode-skill-loader"
|
||||||
|
import { getAgentDisplayName } from "../shared/agent-display-names"
|
||||||
|
import { applyAgentConfig } from "./agent-config-handler"
|
||||||
|
import type { PluginComponents } from "./plugin-components-loader"
|
||||||
|
|
||||||
|
const BUILTIN_SISYPHUS_DISPLAY_NAME = getAgentDisplayName("sisyphus")
|
||||||
|
const BUILTIN_SISYPHUS_JUNIOR_DISPLAY_NAME = getAgentDisplayName("sisyphus-junior")
|
||||||
|
const BUILTIN_MULTIMODAL_LOOKER_DISPLAY_NAME = getAgentDisplayName("multimodal-looker")
|
||||||
|
|
||||||
|
function createPluginComponents(): PluginComponents {
|
||||||
|
return {
|
||||||
|
commands: {},
|
||||||
|
skills: {},
|
||||||
|
agents: {},
|
||||||
|
mcpServers: {},
|
||||||
|
hooksConfigs: [],
|
||||||
|
plugins: [],
|
||||||
|
errors: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBaseConfig(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
model: "anthropic/claude-opus-4-6",
|
||||||
|
agent: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPluginConfig(): OhMyOpenCodeConfig {
|
||||||
|
return {
|
||||||
|
sisyphus_agent: {
|
||||||
|
planner_enabled: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("applyAgentConfig builtin override protection", () => {
|
||||||
|
let createBuiltinAgentsSpy: ReturnType<typeof spyOn>
|
||||||
|
let createSisyphusJuniorAgentSpy: ReturnType<typeof spyOn>
|
||||||
|
let discoverConfigSourceSkillsSpy: ReturnType<typeof spyOn>
|
||||||
|
let discoverUserClaudeSkillsSpy: ReturnType<typeof spyOn>
|
||||||
|
let discoverProjectClaudeSkillsSpy: ReturnType<typeof spyOn>
|
||||||
|
let discoverOpencodeGlobalSkillsSpy: ReturnType<typeof spyOn>
|
||||||
|
let discoverOpencodeProjectSkillsSpy: ReturnType<typeof spyOn>
|
||||||
|
let loadUserAgentsSpy: ReturnType<typeof spyOn>
|
||||||
|
let loadProjectAgentsSpy: ReturnType<typeof spyOn>
|
||||||
|
let migrateAgentConfigSpy: ReturnType<typeof spyOn>
|
||||||
|
let logSpy: ReturnType<typeof spyOn>
|
||||||
|
|
||||||
|
const builtinSisyphusConfig: AgentConfig = {
|
||||||
|
name: "Builtin Sisyphus",
|
||||||
|
prompt: "builtin prompt",
|
||||||
|
mode: "primary",
|
||||||
|
}
|
||||||
|
|
||||||
|
const builtinOracleConfig: AgentConfig = {
|
||||||
|
name: "oracle",
|
||||||
|
prompt: "oracle prompt",
|
||||||
|
mode: "subagent",
|
||||||
|
}
|
||||||
|
|
||||||
|
const builtinMultimodalLookerConfig: AgentConfig = {
|
||||||
|
name: "multimodal-looker",
|
||||||
|
prompt: "multimodal prompt",
|
||||||
|
mode: "subagent",
|
||||||
|
}
|
||||||
|
|
||||||
|
const sisyphusJuniorConfig: AgentConfig = {
|
||||||
|
name: "Sisyphus-Junior",
|
||||||
|
prompt: "junior prompt",
|
||||||
|
mode: "all",
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
createBuiltinAgentsSpy = spyOn(agents, "createBuiltinAgents").mockResolvedValue({
|
||||||
|
sisyphus: builtinSisyphusConfig,
|
||||||
|
oracle: builtinOracleConfig,
|
||||||
|
"multimodal-looker": builtinMultimodalLookerConfig,
|
||||||
|
})
|
||||||
|
|
||||||
|
createSisyphusJuniorAgentSpy = spyOn(
|
||||||
|
sisyphusJunior,
|
||||||
|
"createSisyphusJuniorAgentWithOverrides",
|
||||||
|
).mockReturnValue(sisyphusJuniorConfig)
|
||||||
|
|
||||||
|
discoverConfigSourceSkillsSpy = spyOn(
|
||||||
|
skillLoader,
|
||||||
|
"discoverConfigSourceSkills",
|
||||||
|
).mockResolvedValue([])
|
||||||
|
discoverUserClaudeSkillsSpy = spyOn(
|
||||||
|
skillLoader,
|
||||||
|
"discoverUserClaudeSkills",
|
||||||
|
).mockResolvedValue([])
|
||||||
|
discoverProjectClaudeSkillsSpy = spyOn(
|
||||||
|
skillLoader,
|
||||||
|
"discoverProjectClaudeSkills",
|
||||||
|
).mockResolvedValue([])
|
||||||
|
discoverOpencodeGlobalSkillsSpy = spyOn(
|
||||||
|
skillLoader,
|
||||||
|
"discoverOpencodeGlobalSkills",
|
||||||
|
).mockResolvedValue([])
|
||||||
|
discoverOpencodeProjectSkillsSpy = spyOn(
|
||||||
|
skillLoader,
|
||||||
|
"discoverOpencodeProjectSkills",
|
||||||
|
).mockResolvedValue([])
|
||||||
|
|
||||||
|
loadUserAgentsSpy = spyOn(agentLoader, "loadUserAgents").mockReturnValue({})
|
||||||
|
loadProjectAgentsSpy = spyOn(agentLoader, "loadProjectAgents").mockReturnValue({})
|
||||||
|
|
||||||
|
migrateAgentConfigSpy = spyOn(shared, "migrateAgentConfig").mockImplementation(
|
||||||
|
(config: Record<string, unknown>) => config,
|
||||||
|
)
|
||||||
|
logSpy = spyOn(shared, "log").mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
createBuiltinAgentsSpy.mockRestore()
|
||||||
|
createSisyphusJuniorAgentSpy.mockRestore()
|
||||||
|
discoverConfigSourceSkillsSpy.mockRestore()
|
||||||
|
discoverUserClaudeSkillsSpy.mockRestore()
|
||||||
|
discoverProjectClaudeSkillsSpy.mockRestore()
|
||||||
|
discoverOpencodeGlobalSkillsSpy.mockRestore()
|
||||||
|
discoverOpencodeProjectSkillsSpy.mockRestore()
|
||||||
|
loadUserAgentsSpy.mockRestore()
|
||||||
|
loadProjectAgentsSpy.mockRestore()
|
||||||
|
migrateAgentConfigSpy.mockRestore()
|
||||||
|
logSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("filters user agents whose key matches the builtin display-name alias", async () => {
|
||||||
|
// given
|
||||||
|
loadUserAgentsSpy.mockReturnValue({
|
||||||
|
[BUILTIN_SISYPHUS_DISPLAY_NAME]: {
|
||||||
|
name: BUILTIN_SISYPHUS_DISPLAY_NAME,
|
||||||
|
prompt: "user alias prompt",
|
||||||
|
mode: "subagent",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await applyAgentConfig({
|
||||||
|
config: createBaseConfig(),
|
||||||
|
pluginConfig: createPluginConfig(),
|
||||||
|
ctx: { directory: "/tmp" },
|
||||||
|
pluginComponents: createPluginComponents(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result[BUILTIN_SISYPHUS_DISPLAY_NAME]).toEqual(builtinSisyphusConfig)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("filters user agents whose key differs from a builtin key only by case", async () => {
|
||||||
|
// given
|
||||||
|
loadUserAgentsSpy.mockReturnValue({
|
||||||
|
SiSyPhUs: {
|
||||||
|
name: "SiSyPhUs",
|
||||||
|
prompt: "mixed-case prompt",
|
||||||
|
mode: "subagent",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await applyAgentConfig({
|
||||||
|
config: createBaseConfig(),
|
||||||
|
pluginConfig: createPluginConfig(),
|
||||||
|
ctx: { directory: "/tmp" },
|
||||||
|
pluginComponents: createPluginComponents(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result[BUILTIN_SISYPHUS_DISPLAY_NAME]).toEqual(builtinSisyphusConfig)
|
||||||
|
expect(result.SiSyPhUs).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("filters plugin agents whose key matches the builtin display-name alias", async () => {
|
||||||
|
// given
|
||||||
|
const pluginComponents = createPluginComponents()
|
||||||
|
pluginComponents.agents = {
|
||||||
|
[BUILTIN_SISYPHUS_DISPLAY_NAME]: {
|
||||||
|
name: BUILTIN_SISYPHUS_DISPLAY_NAME,
|
||||||
|
prompt: "plugin alias prompt",
|
||||||
|
mode: "subagent",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await applyAgentConfig({
|
||||||
|
config: createBaseConfig(),
|
||||||
|
pluginConfig: createPluginConfig(),
|
||||||
|
ctx: { directory: "/tmp" },
|
||||||
|
pluginComponents,
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result[BUILTIN_SISYPHUS_DISPLAY_NAME]).toEqual(builtinSisyphusConfig)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("#given protected builtin agents use hyphenated names", () => {
|
||||||
|
describe("#when a user agent uses the underscored multimodal looker alias", () => {
|
||||||
|
test("filters the override", async () => {
|
||||||
|
// given
|
||||||
|
loadUserAgentsSpy.mockReturnValue({
|
||||||
|
multimodal_looker: {
|
||||||
|
name: "multimodal_looker",
|
||||||
|
prompt: "user multimodal alias prompt",
|
||||||
|
mode: "subagent",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await applyAgentConfig({
|
||||||
|
config: createBaseConfig(),
|
||||||
|
pluginConfig: createPluginConfig(),
|
||||||
|
ctx: { directory: "/tmp" },
|
||||||
|
pluginComponents: createPluginComponents(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result[BUILTIN_MULTIMODAL_LOOKER_DISPLAY_NAME]).toEqual(builtinMultimodalLookerConfig)
|
||||||
|
expect(result.multimodal_looker).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("#when a user agent uses the underscored sisyphus junior alias", () => {
|
||||||
|
test("filters the override", async () => {
|
||||||
|
// given
|
||||||
|
loadUserAgentsSpy.mockReturnValue({
|
||||||
|
sisyphus_junior: {
|
||||||
|
name: "sisyphus_junior",
|
||||||
|
prompt: "user junior alias prompt",
|
||||||
|
mode: "subagent",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await applyAgentConfig({
|
||||||
|
config: createBaseConfig(),
|
||||||
|
pluginConfig: createPluginConfig(),
|
||||||
|
ctx: { directory: "/tmp" },
|
||||||
|
pluginComponents: createPluginComponents(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result[BUILTIN_SISYPHUS_JUNIOR_DISPLAY_NAME]).toEqual(sisyphusJuniorConfig)
|
||||||
|
expect(result.sisyphus_junior).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -15,6 +15,10 @@ import { loadProjectAgents, loadUserAgents } from "../features/claude-code-agent
|
|||||||
import type { PluginComponents } from "./plugin-components-loader";
|
import type { PluginComponents } from "./plugin-components-loader";
|
||||||
import { reorderAgentsByPriority } from "./agent-priority-order";
|
import { reorderAgentsByPriority } from "./agent-priority-order";
|
||||||
import { remapAgentKeysToDisplayNames } from "./agent-key-remapper";
|
import { remapAgentKeysToDisplayNames } from "./agent-key-remapper";
|
||||||
|
import {
|
||||||
|
createProtectedAgentNameSet,
|
||||||
|
filterProtectedAgentOverrides,
|
||||||
|
} from "./agent-override-protection";
|
||||||
import { buildPrometheusAgentConfig } from "./prometheus-agent-config-builder";
|
import { buildPrometheusAgentConfig } from "./prometheus-agent-config-builder";
|
||||||
import { buildPlanDemoteConfig } from "./plan-model-inheritance";
|
import { buildPlanDemoteConfig } from "./plan-model-inheritance";
|
||||||
|
|
||||||
@@ -209,19 +213,21 @@ export async function applyAgentConfig(params: {
|
|||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Collect all builtin agent names to prevent user/project .md files from overriding them
|
const protectedBuiltinAgentNames = createProtectedAgentNameSet([
|
||||||
const builtinAgentNames = new Set([
|
|
||||||
...Object.keys(agentConfig),
|
...Object.keys(agentConfig),
|
||||||
...Object.keys(builtinAgents),
|
...Object.keys(builtinAgents),
|
||||||
]);
|
]);
|
||||||
|
const filteredUserAgents = filterProtectedAgentOverrides(
|
||||||
// Filter user/project agents that duplicate builtin agents (they have mode: "subagent" hardcoded
|
userAgents,
|
||||||
// in loadAgentsFromDir which would incorrectly override the builtin mode: "primary")
|
protectedBuiltinAgentNames,
|
||||||
const filteredUserAgents = Object.fromEntries(
|
|
||||||
Object.entries(userAgents).filter(([key]) => !builtinAgentNames.has(key)),
|
|
||||||
);
|
);
|
||||||
const filteredProjectAgents = Object.fromEntries(
|
const filteredProjectAgents = filterProtectedAgentOverrides(
|
||||||
Object.entries(projectAgents).filter(([key]) => !builtinAgentNames.has(key)),
|
projectAgents,
|
||||||
|
protectedBuiltinAgentNames,
|
||||||
|
);
|
||||||
|
const filteredPluginAgents = filterProtectedAgentOverrides(
|
||||||
|
pluginAgents,
|
||||||
|
protectedBuiltinAgentNames,
|
||||||
);
|
);
|
||||||
|
|
||||||
params.config.agent = {
|
params.config.agent = {
|
||||||
@@ -231,26 +237,33 @@ export async function applyAgentConfig(params: {
|
|||||||
),
|
),
|
||||||
...filterDisabledAgents(filteredUserAgents),
|
...filterDisabledAgents(filteredUserAgents),
|
||||||
...filterDisabledAgents(filteredProjectAgents),
|
...filterDisabledAgents(filteredProjectAgents),
|
||||||
...filterDisabledAgents(pluginAgents),
|
...filterDisabledAgents(filteredPluginAgents),
|
||||||
...filteredConfigAgents,
|
...filteredConfigAgents,
|
||||||
build: { ...migratedBuild, mode: "subagent", hidden: true },
|
build: { ...migratedBuild, mode: "subagent", hidden: true },
|
||||||
...(planDemoteConfig ? { plan: planDemoteConfig } : {}),
|
...(planDemoteConfig ? { plan: planDemoteConfig } : {}),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// Filter user/project agents that duplicate builtin agents
|
const protectedBuiltinAgentNames = createProtectedAgentNameSet(
|
||||||
const builtinAgentNames = new Set(Object.keys(builtinAgents));
|
Object.keys(builtinAgents),
|
||||||
const filteredUserAgents = Object.fromEntries(
|
|
||||||
Object.entries(userAgents).filter(([key]) => !builtinAgentNames.has(key)),
|
|
||||||
);
|
);
|
||||||
const filteredProjectAgents = Object.fromEntries(
|
const filteredUserAgents = filterProtectedAgentOverrides(
|
||||||
Object.entries(projectAgents).filter(([key]) => !builtinAgentNames.has(key)),
|
userAgents,
|
||||||
|
protectedBuiltinAgentNames,
|
||||||
|
);
|
||||||
|
const filteredProjectAgents = filterProtectedAgentOverrides(
|
||||||
|
projectAgents,
|
||||||
|
protectedBuiltinAgentNames,
|
||||||
|
);
|
||||||
|
const filteredPluginAgents = filterProtectedAgentOverrides(
|
||||||
|
pluginAgents,
|
||||||
|
protectedBuiltinAgentNames,
|
||||||
);
|
);
|
||||||
|
|
||||||
params.config.agent = {
|
params.config.agent = {
|
||||||
...builtinAgents,
|
...builtinAgents,
|
||||||
...filterDisabledAgents(filteredUserAgents),
|
...filterDisabledAgents(filteredUserAgents),
|
||||||
...filterDisabledAgents(filteredProjectAgents),
|
...filterDisabledAgents(filteredProjectAgents),
|
||||||
...filterDisabledAgents(pluginAgents),
|
...filterDisabledAgents(filteredPluginAgents),
|
||||||
...configAgent,
|
...configAgent,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
const PARENTHETICAL_SUFFIX_PATTERN = /\s*(\([^)]*\)\s*)+$/u
|
||||||
|
|
||||||
|
export function normalizeProtectedAgentName(agentName: string): string {
|
||||||
|
return agentName
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(PARENTHETICAL_SUFFIX_PATTERN, "")
|
||||||
|
.replace(/[-_]/g, "")
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createProtectedAgentNameSet(agentNames: Iterable<string>): Set<string> {
|
||||||
|
const protectedAgentNames = new Set<string>()
|
||||||
|
|
||||||
|
for (const agentName of agentNames) {
|
||||||
|
const normalizedAgentName = normalizeProtectedAgentName(agentName)
|
||||||
|
if (normalizedAgentName.length === 0) continue
|
||||||
|
|
||||||
|
protectedAgentNames.add(normalizedAgentName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return protectedAgentNames
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterProtectedAgentOverrides<TAgent>(
|
||||||
|
agents: Record<string, TAgent>,
|
||||||
|
protectedAgentNames: ReadonlySet<string>,
|
||||||
|
): Record<string, TAgent> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(agents).filter(([agentName]) => {
|
||||||
|
return !protectedAgentNames.has(normalizeProtectedAgentName(agentName))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -212,8 +212,8 @@ describe("createEventHandler - model fallback", () => {
|
|||||||
expect(abortCalls).toEqual([sessionID])
|
expect(abortCalls).toEqual([sessionID])
|
||||||
expect(promptCalls).toEqual([sessionID])
|
expect(promptCalls).toEqual([sessionID])
|
||||||
expect(output.message["model"]).toMatchObject({
|
expect(output.message["model"]).toMatchObject({
|
||||||
providerID: "kimi-for-coding",
|
providerID: "opencode-go",
|
||||||
modelID: "k2p5",
|
modelID: "kimi-k2.5",
|
||||||
})
|
})
|
||||||
expect(output.message["variant"]).toBeUndefined()
|
expect(output.message["variant"]).toBeUndefined()
|
||||||
})
|
})
|
||||||
@@ -540,19 +540,19 @@ describe("createEventHandler - model fallback", () => {
|
|||||||
|
|
||||||
//#then - first fallback entry applied (no-op skip: claude-opus-4-6 matches current model after normalization)
|
//#then - first fallback entry applied (no-op skip: claude-opus-4-6 matches current model after normalization)
|
||||||
expect(first.message["model"]).toMatchObject({
|
expect(first.message["model"]).toMatchObject({
|
||||||
providerID: "kimi-for-coding",
|
providerID: "opencode-go",
|
||||||
modelID: "k2p5",
|
modelID: "kimi-k2.5",
|
||||||
})
|
})
|
||||||
expect(first.message["variant"]).toBeUndefined()
|
expect(first.message["variant"]).toBeUndefined()
|
||||||
|
|
||||||
//#when - second retry cycle
|
//#when - second retry cycle
|
||||||
const second = await triggerRetryCycle()
|
const second = await triggerRetryCycle()
|
||||||
|
|
||||||
//#then - second fallback entry applied (chain advanced past k2p5)
|
//#then - second fallback entry applied (chain advanced past opencode-go/kimi-k2.5)
|
||||||
expect(second.message["model"]).toMatchObject({
|
expect(second.message["model"]).toMatchObject({
|
||||||
modelID: "kimi-k2.5",
|
providerID: "kimi-for-coding",
|
||||||
|
modelID: "k2p5",
|
||||||
})
|
})
|
||||||
expect((second.message["model"] as { providerID?: string })?.providerID).toBeTruthy()
|
|
||||||
expect(second.message["variant"]).toBeUndefined()
|
expect(second.message["variant"]).toBeUndefined()
|
||||||
expect(abortCalls).toEqual([sessionID, sessionID])
|
expect(abortCalls).toEqual([sessionID, sessionID])
|
||||||
expect(promptCalls).toEqual([sessionID, sessionID])
|
expect(promptCalls).toEqual([sessionID, sessionID])
|
||||||
|
|||||||
@@ -319,6 +319,7 @@ export function createEventHandler(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sessionInfo?.id) {
|
if (sessionInfo?.id) {
|
||||||
|
const wasSyncSubagentSession = syncSubagentSessions.has(sessionInfo.id);
|
||||||
clearSessionAgent(sessionInfo.id);
|
clearSessionAgent(sessionInfo.id);
|
||||||
lastHandledModelErrorMessageID.delete(sessionInfo.id);
|
lastHandledModelErrorMessageID.delete(sessionInfo.id);
|
||||||
lastHandledRetryStatusKey.delete(sessionInfo.id);
|
lastHandledRetryStatusKey.delete(sessionInfo.id);
|
||||||
@@ -329,6 +330,9 @@ export function createEventHandler(args: {
|
|||||||
firstMessageVariantGate.clear(sessionInfo.id);
|
firstMessageVariantGate.clear(sessionInfo.id);
|
||||||
clearSessionModel(sessionInfo.id);
|
clearSessionModel(sessionInfo.id);
|
||||||
syncSubagentSessions.delete(sessionInfo.id);
|
syncSubagentSessions.delete(sessionInfo.id);
|
||||||
|
if (wasSyncSubagentSession) {
|
||||||
|
subagentSessions.delete(sessionInfo.id);
|
||||||
|
}
|
||||||
deleteSessionTools(sessionInfo.id);
|
deleteSessionTools(sessionInfo.id);
|
||||||
await managers.skillMcpManager.disconnectSession(sessionInfo.id);
|
await managers.skillMcpManager.disconnectSession(sessionInfo.id);
|
||||||
await lspManager.cleanupTempDirectoryClients();
|
await lspManager.cleanupTempDirectoryClients();
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "bun:test"
|
||||||
|
|
||||||
|
import {
|
||||||
|
_resetForTesting,
|
||||||
|
subagentSessions,
|
||||||
|
syncSubagentSessions,
|
||||||
|
} from "../../features/claude-code-session-state"
|
||||||
|
import { createEventHandler } from "../../plugin/event"
|
||||||
|
|
||||||
|
function createMinimalEventHandler() {
|
||||||
|
return createEventHandler({
|
||||||
|
ctx: {} as never,
|
||||||
|
pluginConfig: {} as never,
|
||||||
|
firstMessageVariantGate: {
|
||||||
|
markSessionCreated: () => {},
|
||||||
|
clear: () => {},
|
||||||
|
},
|
||||||
|
managers: {
|
||||||
|
tmuxSessionManager: {
|
||||||
|
onSessionCreated: async () => {},
|
||||||
|
onSessionDeleted: async () => {},
|
||||||
|
},
|
||||||
|
skillMcpManager: {
|
||||||
|
disconnectSession: async () => {},
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
hooks: {
|
||||||
|
autoUpdateChecker: { event: async () => {} },
|
||||||
|
claudeCodeHooks: { event: async () => {} },
|
||||||
|
backgroundNotificationHook: { event: async () => {} },
|
||||||
|
sessionNotification: async () => {},
|
||||||
|
todoContinuationEnforcer: { handler: async () => {} },
|
||||||
|
unstableAgentBabysitter: { event: async () => {} },
|
||||||
|
contextWindowMonitor: { event: async () => {} },
|
||||||
|
directoryAgentsInjector: { event: async () => {} },
|
||||||
|
directoryReadmeInjector: { event: async () => {} },
|
||||||
|
rulesInjector: { event: async () => {} },
|
||||||
|
thinkMode: { event: async () => {} },
|
||||||
|
anthropicContextWindowLimitRecovery: { event: async () => {} },
|
||||||
|
runtimeFallback: undefined,
|
||||||
|
modelFallback: undefined,
|
||||||
|
agentUsageReminder: { event: async () => {} },
|
||||||
|
categorySkillReminder: { event: async () => {} },
|
||||||
|
interactiveBashSession: { event: async () => {} },
|
||||||
|
ralphLoop: { event: async () => {} },
|
||||||
|
stopContinuationGuard: { event: async () => {}, isStopped: () => false },
|
||||||
|
compactionTodoPreserver: { event: async () => {} },
|
||||||
|
writeExistingFileGuard: { event: async () => {} },
|
||||||
|
atlasHook: { handler: async () => {} },
|
||||||
|
} as never,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("reused sync session delete cleanup", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
_resetForTesting()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("removes reused sync sessions from subagentSessions when session.deleted fires", async () => {
|
||||||
|
// given
|
||||||
|
const syncSessionID = "ses-reused-sync-delete-cleanup"
|
||||||
|
const unrelatedSubagentSessionID = "ses-unrelated-subagent-delete-cleanup"
|
||||||
|
const eventHandler = createMinimalEventHandler()
|
||||||
|
const input = {
|
||||||
|
event: {
|
||||||
|
type: "session.deleted",
|
||||||
|
properties: {
|
||||||
|
info: {
|
||||||
|
id: syncSessionID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as Parameters<ReturnType<typeof createEventHandler>>[0]
|
||||||
|
|
||||||
|
subagentSessions.add(syncSessionID)
|
||||||
|
syncSubagentSessions.add(syncSessionID)
|
||||||
|
subagentSessions.add(unrelatedSubagentSessionID)
|
||||||
|
|
||||||
|
// when
|
||||||
|
await eventHandler(input)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(syncSubagentSessions.has(syncSessionID)).toBe(false)
|
||||||
|
expect(subagentSessions.has(syncSessionID)).toBe(false)
|
||||||
|
expect(subagentSessions.has(unrelatedSubagentSessionID)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ExecutorContext } from "./executor-types"
|
||||||
|
|
||||||
|
export async function cancelUnstableAgentTask(
|
||||||
|
manager: ExecutorContext["manager"],
|
||||||
|
taskID: string | undefined,
|
||||||
|
reason: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (!taskID || typeof manager.cancelTask !== "function") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.allSettled([
|
||||||
|
manager.cancelTask(taskID, {
|
||||||
|
source: "unstable-agent-task",
|
||||||
|
reason,
|
||||||
|
skipNotification: true,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
declare const require: (name: string) => any
|
||||||
|
const { describe, test, expect, beforeEach, afterEach } = require("bun:test")
|
||||||
|
|
||||||
|
import { __resetTimingConfig, __setTimingConfig } from "./timing"
|
||||||
|
|
||||||
|
function createArgs() {
|
||||||
|
return {
|
||||||
|
description: "cleanup case",
|
||||||
|
prompt: "run",
|
||||||
|
category: "unspecified-low",
|
||||||
|
run_in_background: false,
|
||||||
|
load_skills: [],
|
||||||
|
command: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createToolContext(aborted = false) {
|
||||||
|
const controller = new AbortController()
|
||||||
|
if (aborted) {
|
||||||
|
controller.abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionID: "parent-session",
|
||||||
|
messageID: "parent-message",
|
||||||
|
agent: "test-agent",
|
||||||
|
abort: controller.signal,
|
||||||
|
metadata: () => Promise.resolve(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createParentContext() {
|
||||||
|
return {
|
||||||
|
sessionID: "parent-session",
|
||||||
|
messageID: "parent-message",
|
||||||
|
model: "gpt-test",
|
||||||
|
agent: "test-agent",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("executeUnstableAgentTask cleanup", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
__setTimingConfig({
|
||||||
|
POLL_INTERVAL_MS: 10,
|
||||||
|
MIN_STABILITY_TIME_MS: 0,
|
||||||
|
STABILITY_POLLS_REQUIRED: 1,
|
||||||
|
WAIT_FOR_SESSION_TIMEOUT_MS: 100,
|
||||||
|
WAIT_FOR_SESSION_INTERVAL_MS: 10,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
__resetTimingConfig()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("cancels launched task when parent aborts during monitoring", async () => {
|
||||||
|
// given
|
||||||
|
const { executeUnstableAgentTask } = require("./unstable-agent-task")
|
||||||
|
const cancelCalls: Array<{ taskId: string; options?: Record<string, unknown> }> = []
|
||||||
|
|
||||||
|
const mockManager = {
|
||||||
|
launch: async () => ({ id: "bg_abort_monitoring", sessionID: "ses_abort_monitoring", status: "running" }),
|
||||||
|
getTask: () => ({ id: "bg_abort_monitoring", sessionID: "ses_abort_monitoring", status: "running" }),
|
||||||
|
cancelTask: async (taskId: string, options?: Record<string, unknown>) => {
|
||||||
|
cancelCalls.push({ taskId, options })
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await executeUnstableAgentTask(
|
||||||
|
createArgs(),
|
||||||
|
createToolContext(true),
|
||||||
|
{
|
||||||
|
manager: mockManager,
|
||||||
|
client: {
|
||||||
|
session: {
|
||||||
|
status: async () => ({ data: {} }),
|
||||||
|
messages: async () => ({ data: [] }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createParentContext(),
|
||||||
|
"test-agent",
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
"gpt-test"
|
||||||
|
)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toContain("Task aborted (was running in background mode).")
|
||||||
|
expect(cancelCalls).toHaveLength(1)
|
||||||
|
expect(cancelCalls[0]?.taskId).toBe("bg_abort_monitoring")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("cancels launched task when monitored timeout budget is exhausted", async () => {
|
||||||
|
// given
|
||||||
|
const { executeUnstableAgentTask } = require("./unstable-agent-task")
|
||||||
|
const cancelCalls: Array<{ taskId: string; options?: Record<string, unknown> }> = []
|
||||||
|
|
||||||
|
const mockManager = {
|
||||||
|
launch: async () => ({ id: "bg_timeout_cleanup", sessionID: "ses_timeout_cleanup", status: "running" }),
|
||||||
|
getTask: () => ({ id: "bg_timeout_cleanup", sessionID: "ses_timeout_cleanup", status: "running" }),
|
||||||
|
cancelTask: async (taskId: string, options?: Record<string, unknown>) => {
|
||||||
|
cancelCalls.push({ taskId, options })
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await executeUnstableAgentTask(
|
||||||
|
createArgs(),
|
||||||
|
createToolContext(),
|
||||||
|
{
|
||||||
|
manager: mockManager,
|
||||||
|
client: {
|
||||||
|
session: {
|
||||||
|
status: async () => ({ data: { ses_timeout_cleanup: { type: "busy" } } }),
|
||||||
|
messages: async () => ({ data: [] }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
syncPollTimeoutMs: 0,
|
||||||
|
},
|
||||||
|
createParentContext(),
|
||||||
|
"test-agent",
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
"gpt-test"
|
||||||
|
)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toContain("SUPERVISED TASK TIMED OUT")
|
||||||
|
expect(cancelCalls).toHaveLength(1)
|
||||||
|
expect(cancelCalls[0]?.taskId).toBe("bg_timeout_cleanup")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("cancels launched task when parent aborts while waiting for session start", async () => {
|
||||||
|
// given
|
||||||
|
const { executeUnstableAgentTask } = require("./unstable-agent-task")
|
||||||
|
const cancelCalls: Array<{ taskId: string; options?: Record<string, unknown> }> = []
|
||||||
|
|
||||||
|
const mockManager = {
|
||||||
|
launch: async () => ({ id: "bg_wait_abort", status: "pending" }),
|
||||||
|
getTask: () => ({ id: "bg_wait_abort", status: "pending" }),
|
||||||
|
cancelTask: async (taskId: string, options?: Record<string, unknown>) => {
|
||||||
|
cancelCalls.push({ taskId, options })
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await executeUnstableAgentTask(
|
||||||
|
createArgs(),
|
||||||
|
createToolContext(true),
|
||||||
|
{
|
||||||
|
manager: mockManager,
|
||||||
|
client: {
|
||||||
|
session: {
|
||||||
|
status: async () => ({ data: {} }),
|
||||||
|
messages: async () => ({ data: [] }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createParentContext(),
|
||||||
|
"test-agent",
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
"gpt-test"
|
||||||
|
)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toContain("Task aborted while waiting for session to start.")
|
||||||
|
expect(cancelCalls).toHaveLength(1)
|
||||||
|
expect(cancelCalls[0]?.taskId).toBe("bg_wait_abort")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,6 +2,7 @@ import type { DelegateTaskArgs, ToolContextWithMetadata } from "./types"
|
|||||||
import type { ExecutorContext, ParentContext, SessionMessage } from "./executor-types"
|
import type { ExecutorContext, ParentContext, SessionMessage } from "./executor-types"
|
||||||
import { DEFAULT_SYNC_POLL_TIMEOUT_MS, getTimingConfig } from "./timing"
|
import { DEFAULT_SYNC_POLL_TIMEOUT_MS, getTimingConfig } from "./timing"
|
||||||
import { buildTaskPrompt } from "./prompt-builder"
|
import { buildTaskPrompt } from "./prompt-builder"
|
||||||
|
import { cancelUnstableAgentTask } from "./cancel-unstable-agent-task"
|
||||||
import { storeToolMetadata } from "../../features/tool-metadata-store"
|
import { storeToolMetadata } from "../../features/tool-metadata-store"
|
||||||
import { formatDuration } from "./time-formatter"
|
import { formatDuration } from "./time-formatter"
|
||||||
import { formatDetailedError } from "./error-formatting"
|
import { formatDetailedError } from "./error-formatting"
|
||||||
@@ -20,6 +21,8 @@ export async function executeUnstableAgentTask(
|
|||||||
actualModel: string | undefined
|
actualModel: string | undefined
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const { manager, client, syncPollTimeoutMs } = executorCtx
|
const { manager, client, syncPollTimeoutMs } = executorCtx
|
||||||
|
let cleanupReason: string | undefined
|
||||||
|
let launchedTaskID: string | undefined
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const effectivePrompt = buildTaskPrompt(args.prompt, agentToUse)
|
const effectivePrompt = buildTaskPrompt(args.prompt, agentToUse)
|
||||||
@@ -38,12 +41,14 @@ export async function executeUnstableAgentTask(
|
|||||||
category: args.category,
|
category: args.category,
|
||||||
sessionPermission: QUESTION_DENIED_SESSION_PERMISSION,
|
sessionPermission: QUESTION_DENIED_SESSION_PERMISSION,
|
||||||
})
|
})
|
||||||
|
launchedTaskID = task.id
|
||||||
|
|
||||||
const timing = getTimingConfig()
|
const timing = getTimingConfig()
|
||||||
const waitStart = Date.now()
|
const waitStart = Date.now()
|
||||||
let sessionID = task.sessionID
|
let sessionID = task.sessionID
|
||||||
while (!sessionID && Date.now() - waitStart < timing.WAIT_FOR_SESSION_TIMEOUT_MS) {
|
while (!sessionID && Date.now() - waitStart < timing.WAIT_FOR_SESSION_TIMEOUT_MS) {
|
||||||
if (ctx.abort?.aborted) {
|
if (ctx.abort?.aborted) {
|
||||||
|
cleanupReason = "Parent aborted while waiting for unstable task session start"
|
||||||
return `Task aborted while waiting for session to start.\n\nTask ID: ${task.id}`
|
return `Task aborted while waiting for session to start.\n\nTask ID: ${task.id}`
|
||||||
}
|
}
|
||||||
await new Promise(resolve => setTimeout(resolve, timing.WAIT_FOR_SESSION_INTERVAL_MS))
|
await new Promise(resolve => setTimeout(resolve, timing.WAIT_FOR_SESSION_INTERVAL_MS))
|
||||||
@@ -51,6 +56,7 @@ export async function executeUnstableAgentTask(
|
|||||||
sessionID = updated?.sessionID
|
sessionID = updated?.sessionID
|
||||||
}
|
}
|
||||||
if (!sessionID) {
|
if (!sessionID) {
|
||||||
|
cleanupReason = "Unstable task session start timed out before session became available"
|
||||||
return formatDetailedError(new Error(`Task failed to start within timeout (30s). Task ID: ${task.id}, Status: ${task.status}`), {
|
return formatDetailedError(new Error(`Task failed to start within timeout (30s). Task ID: ${task.id}, Status: ${task.status}`), {
|
||||||
operation: "Launch monitored background task",
|
operation: "Launch monitored background task",
|
||||||
args,
|
args,
|
||||||
@@ -88,6 +94,7 @@ export async function executeUnstableAgentTask(
|
|||||||
|
|
||||||
while (Date.now() - pollStart < (syncPollTimeoutMs ?? DEFAULT_SYNC_POLL_TIMEOUT_MS)) {
|
while (Date.now() - pollStart < (syncPollTimeoutMs ?? DEFAULT_SYNC_POLL_TIMEOUT_MS)) {
|
||||||
if (ctx.abort?.aborted) {
|
if (ctx.abort?.aborted) {
|
||||||
|
cleanupReason = "Parent aborted while monitoring unstable background task"
|
||||||
return `Task aborted (was running in background mode).\n\nSession ID: ${sessionID}`
|
return `Task aborted (was running in background mode).\n\nSession ID: ${sessionID}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +155,7 @@ session_id: ${sessionID}
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!completedDuringMonitoring) {
|
if (!completedDuringMonitoring) {
|
||||||
|
cleanupReason = "Monitored unstable background task exceeded timeout budget"
|
||||||
const duration = formatDuration(startTime)
|
const duration = formatDuration(startTime)
|
||||||
const timeoutBudgetMs = syncPollTimeoutMs ?? DEFAULT_SYNC_POLL_TIMEOUT_MS
|
const timeoutBudgetMs = syncPollTimeoutMs ?? DEFAULT_SYNC_POLL_TIMEOUT_MS
|
||||||
return `SUPERVISED TASK TIMED OUT
|
return `SUPERVISED TASK TIMED OUT
|
||||||
@@ -215,11 +223,18 @@ ${textContent || "(No text output)"}
|
|||||||
session_id: ${sessionID}
|
session_id: ${sessionID}
|
||||||
</task_metadata>`
|
</task_metadata>`
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!cleanupReason) {
|
||||||
|
cleanupReason = "exception"
|
||||||
|
}
|
||||||
return formatDetailedError(error, {
|
return formatDetailedError(error, {
|
||||||
operation: "Launch monitored background task",
|
operation: "Launch monitored background task",
|
||||||
args,
|
args,
|
||||||
agent: agentToUse,
|
agent: agentToUse,
|
||||||
category: args.category,
|
category: args.category,
|
||||||
})
|
})
|
||||||
|
} finally {
|
||||||
|
if (cleanupReason) {
|
||||||
|
await cancelUnstableAgentTask(manager, launchedTaskID, cleanupReason)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
||||||
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import { executeSlashCommand } from "../../hooks/auto-slash-command/executor"
|
||||||
|
import { discoverCommandsSync } from "./command-discovery"
|
||||||
|
|
||||||
|
describe("slashcommand discovery and execution compatibility", () => {
|
||||||
|
let tempDir = ""
|
||||||
|
let originalWorkingDirectory = ""
|
||||||
|
let originalOpencodeConfigDir: string | undefined
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = mkdtempSync(join(tmpdir(), "omo-slashcommand-compat-test-"))
|
||||||
|
originalWorkingDirectory = process.cwd()
|
||||||
|
originalOpencodeConfigDir = process.env.OPENCODE_CONFIG_DIR
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.chdir(originalWorkingDirectory)
|
||||||
|
|
||||||
|
if (originalOpencodeConfigDir === undefined) {
|
||||||
|
delete process.env.OPENCODE_CONFIG_DIR
|
||||||
|
} else {
|
||||||
|
process.env.OPENCODE_CONFIG_DIR = originalOpencodeConfigDir
|
||||||
|
}
|
||||||
|
|
||||||
|
rmSync(tempDir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("executes commands discovered from a parent opencode config dir", async () => {
|
||||||
|
// given
|
||||||
|
const projectDir = join(tempDir, "project")
|
||||||
|
const opencodeRootDir = join(tempDir, "opencode-root")
|
||||||
|
const profileConfigDir = join(opencodeRootDir, "profiles", "codex")
|
||||||
|
const parentCommandDir = join(opencodeRootDir, "command")
|
||||||
|
const commandName = "parent-only-command"
|
||||||
|
|
||||||
|
mkdirSync(projectDir, { recursive: true })
|
||||||
|
mkdirSync(profileConfigDir, { recursive: true })
|
||||||
|
mkdirSync(parentCommandDir, { recursive: true })
|
||||||
|
writeFileSync(
|
||||||
|
join(parentCommandDir, `${commandName}.md`),
|
||||||
|
`---\ndescription: Parent config command\n---\nExecute from parent config.\n`,
|
||||||
|
)
|
||||||
|
process.env.OPENCODE_CONFIG_DIR = profileConfigDir
|
||||||
|
process.chdir(projectDir)
|
||||||
|
|
||||||
|
expect(discoverCommandsSync(projectDir).some(command => command.name === commandName)).toBe(true)
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await executeSlashCommand({
|
||||||
|
command: commandName,
|
||||||
|
args: "",
|
||||||
|
raw: `/${commandName}`,
|
||||||
|
}, { skills: [] })
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.replacementText).toContain("Execute from parent config.")
|
||||||
|
expect(result.replacementText).toContain("**Scope**: opencode")
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user