Merge remote-tracking branch 'origin/dev' into fix/sync-package-json-to-opencode-intent
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { buildAntiDuplicationSection } from "./dynamic-agent-prompt-builder"
|
||||
import { METIS_SYSTEM_PROMPT } from "./metis"
|
||||
|
||||
describe("buildAntiDuplicationSection", () => {
|
||||
it("#given no arguments #when building anti-duplication section #then returns comprehensive rule section", () => {
|
||||
//#given: no special configuration needed
|
||||
|
||||
//#when: building the anti-duplication section
|
||||
const result = buildAntiDuplicationSection()
|
||||
|
||||
//#then: should contain the anti-duplication rule with all key concepts
|
||||
expect(result).toContain("Anti-Duplication Rule")
|
||||
expect(result).toContain("CRITICAL")
|
||||
expect(result).toContain("DO NOT perform the same search yourself")
|
||||
})
|
||||
|
||||
it("#given no arguments #when building #then explicitly forbids manual re-search after delegation", () => {
|
||||
//#given: no special configuration
|
||||
|
||||
//#when: building the section
|
||||
const result = buildAntiDuplicationSection()
|
||||
|
||||
//#then: should explicitly list forbidden behaviors
|
||||
expect(result).toContain("FORBIDDEN")
|
||||
expect(result).toContain("manually grep/search for the same information")
|
||||
expect(result).toContain("Re-doing the research")
|
||||
})
|
||||
|
||||
it("#given no arguments #when building #then allows non-overlapping work", () => {
|
||||
//#given: no special configuration
|
||||
|
||||
//#when: building the section
|
||||
const result = buildAntiDuplicationSection()
|
||||
|
||||
//#then: should explicitly allow non-overlapping work
|
||||
expect(result).toContain("ALLOWED")
|
||||
expect(result).toContain("non-overlapping work")
|
||||
expect(result).toContain("work that doesn't depend on the delegated research")
|
||||
})
|
||||
|
||||
it("#given no arguments #when building #then includes wait-for-results instructions", () => {
|
||||
//#given: no special configuration
|
||||
|
||||
//#when: building the section
|
||||
const result = buildAntiDuplicationSection()
|
||||
|
||||
//#then: should include instructions for waiting properly
|
||||
expect(result).toContain("Wait for Results Properly")
|
||||
expect(result).toContain("End your response")
|
||||
expect(result).toContain("Wait for the completion notification")
|
||||
expect(result).toContain("background_output")
|
||||
})
|
||||
|
||||
it("#given no arguments #when building #then explains why this matters", () => {
|
||||
//#given: no special configuration
|
||||
|
||||
//#when: building the section
|
||||
const result = buildAntiDuplicationSection()
|
||||
|
||||
//#then: should explain the purpose
|
||||
expect(result).toContain("Why This Matters")
|
||||
expect(result).toContain("Wasted tokens")
|
||||
expect(result).toContain("Confusion")
|
||||
expect(result).toContain("Efficiency")
|
||||
})
|
||||
|
||||
it("#given no arguments #when building #then provides code examples", () => {
|
||||
//#given: no special configuration
|
||||
|
||||
//#when: building the section
|
||||
const result = buildAntiDuplicationSection()
|
||||
|
||||
//#then: should include examples
|
||||
expect(result).toContain("Example")
|
||||
expect(result).toContain("WRONG")
|
||||
expect(result).toContain("CORRECT")
|
||||
expect(result).toContain("task(subagent_type=")
|
||||
})
|
||||
|
||||
it("#given no arguments #when building #then uses proper markdown formatting", () => {
|
||||
//#given: no special configuration
|
||||
|
||||
//#when: building the section
|
||||
const result = buildAntiDuplicationSection()
|
||||
|
||||
//#then: should be wrapped in Anti_Duplication tag
|
||||
expect(result).toContain("<Anti_Duplication>")
|
||||
expect(result).toContain("</Anti_Duplication>")
|
||||
})
|
||||
})
|
||||
|
||||
describe("METIS_SYSTEM_PROMPT anti-duplication coverage", () => {
|
||||
it("#given the system prompt #when reading delegated exploration rules #then includes anti-duplication guidance", () => {
|
||||
// given
|
||||
const prompt = METIS_SYSTEM_PROMPT
|
||||
|
||||
// when / then
|
||||
expect(prompt).toContain("<Anti_Duplication>")
|
||||
expect(prompt).toContain("Anti-Duplication Rule")
|
||||
expect(prompt).toContain("DO NOT perform the same search yourself")
|
||||
expect(prompt).toContain("non-overlapping work")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { ATLAS_SYSTEM_PROMPT } from "./default"
|
||||
import { ATLAS_GPT_SYSTEM_PROMPT } from "./gpt"
|
||||
import { ATLAS_GEMINI_SYSTEM_PROMPT } from "./gemini"
|
||||
|
||||
describe("Atlas prompts auto-continue policy", () => {
|
||||
test("default variant should forbid asking user for continuation confirmation", () => {
|
||||
// given
|
||||
const prompt = ATLAS_SYSTEM_PROMPT
|
||||
|
||||
// when
|
||||
const lowerPrompt = prompt.toLowerCase()
|
||||
|
||||
// then
|
||||
expect(lowerPrompt).toContain("auto-continue policy")
|
||||
expect(lowerPrompt).toContain("never ask the user")
|
||||
expect(lowerPrompt).toContain("should i continue")
|
||||
expect(lowerPrompt).toContain("proceed to next task")
|
||||
expect(lowerPrompt).toContain("approval-style")
|
||||
expect(lowerPrompt).toContain("auto-continue immediately")
|
||||
})
|
||||
|
||||
test("gpt variant should forbid asking user for continuation confirmation", () => {
|
||||
// given
|
||||
const prompt = ATLAS_GPT_SYSTEM_PROMPT
|
||||
|
||||
// when
|
||||
const lowerPrompt = prompt.toLowerCase()
|
||||
|
||||
// then
|
||||
expect(lowerPrompt).toContain("auto-continue policy")
|
||||
expect(lowerPrompt).toContain("never ask the user")
|
||||
expect(lowerPrompt).toContain("should i continue")
|
||||
expect(lowerPrompt).toContain("proceed to next task")
|
||||
expect(lowerPrompt).toContain("approval-style")
|
||||
expect(lowerPrompt).toContain("auto-continue immediately")
|
||||
})
|
||||
|
||||
test("gemini variant should forbid asking user for continuation confirmation", () => {
|
||||
// given
|
||||
const prompt = ATLAS_GEMINI_SYSTEM_PROMPT
|
||||
|
||||
// when
|
||||
const lowerPrompt = prompt.toLowerCase()
|
||||
|
||||
// then
|
||||
expect(lowerPrompt).toContain("auto-continue policy")
|
||||
expect(lowerPrompt).toContain("never ask the user")
|
||||
expect(lowerPrompt).toContain("should i continue")
|
||||
expect(lowerPrompt).toContain("proceed to next task")
|
||||
expect(lowerPrompt).toContain("approval-style")
|
||||
expect(lowerPrompt).toContain("auto-continue immediately")
|
||||
})
|
||||
|
||||
test("all variants should require immediate continuation after verification passes", () => {
|
||||
// given
|
||||
const prompts = [ATLAS_SYSTEM_PROMPT, ATLAS_GPT_SYSTEM_PROMPT, ATLAS_GEMINI_SYSTEM_PROMPT]
|
||||
|
||||
// when / then
|
||||
for (const prompt of prompts) {
|
||||
const lowerPrompt = prompt.toLowerCase()
|
||||
expect(lowerPrompt).toMatch(/auto-continue immediately after verification/)
|
||||
expect(lowerPrompt).toMatch(/immediately delegate next task/)
|
||||
}
|
||||
})
|
||||
|
||||
test("all variants should define when user interaction is actually needed", () => {
|
||||
// given
|
||||
const prompts = [ATLAS_SYSTEM_PROMPT, ATLAS_GPT_SYSTEM_PROMPT, ATLAS_GEMINI_SYSTEM_PROMPT]
|
||||
|
||||
// when / then
|
||||
for (const prompt of prompts) {
|
||||
const lowerPrompt = prompt.toLowerCase()
|
||||
expect(lowerPrompt).toMatch(/only pause.*truly blocked/)
|
||||
expect(lowerPrompt).toMatch(/plan needs clarification|blocked by external/)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("Atlas prompts anti-duplication coverage", () => {
|
||||
test("all variants should include anti-duplication rules for delegated exploration", () => {
|
||||
// given
|
||||
const prompts = [ATLAS_SYSTEM_PROMPT, ATLAS_GPT_SYSTEM_PROMPT, ATLAS_GEMINI_SYSTEM_PROMPT]
|
||||
|
||||
// when / then
|
||||
for (const prompt of prompts) {
|
||||
expect(prompt).toContain("<Anti_Duplication>")
|
||||
expect(prompt).toContain("Anti-Duplication Rule")
|
||||
expect(prompt).toContain("DO NOT perform the same search yourself")
|
||||
expect(prompt).toContain("non-overlapping work")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("Atlas prompts plan path consistency", () => {
|
||||
test("default variant should use .sisyphus/plans/{plan-name}.md path", () => {
|
||||
// given
|
||||
const prompt = ATLAS_SYSTEM_PROMPT
|
||||
|
||||
// when / then
|
||||
expect(prompt).toContain(".sisyphus/plans/{plan-name}.md")
|
||||
expect(prompt).not.toContain(".sisyphus/tasks/{plan-name}.yaml")
|
||||
expect(prompt).not.toContain(".sisyphus/tasks/")
|
||||
})
|
||||
|
||||
test("gpt variant should use .sisyphus/plans/{plan-name}.md path", () => {
|
||||
// given
|
||||
const prompt = ATLAS_GPT_SYSTEM_PROMPT
|
||||
|
||||
// when / then
|
||||
expect(prompt).toContain(".sisyphus/plans/{plan-name}.md")
|
||||
expect(prompt).not.toContain(".sisyphus/tasks/")
|
||||
})
|
||||
|
||||
test("gemini variant should use .sisyphus/plans/{plan-name}.md path", () => {
|
||||
// given
|
||||
const prompt = ATLAS_GEMINI_SYSTEM_PROMPT
|
||||
|
||||
// when / then
|
||||
expect(prompt).toContain(".sisyphus/plans/{plan-name}.md")
|
||||
expect(prompt).not.toContain(".sisyphus/tasks/")
|
||||
})
|
||||
|
||||
test("all variants should read plan file after verification", () => {
|
||||
// given
|
||||
const prompts = [ATLAS_SYSTEM_PROMPT, ATLAS_GPT_SYSTEM_PROMPT, ATLAS_GEMINI_SYSTEM_PROMPT]
|
||||
|
||||
// when / then
|
||||
for (const prompt of prompts) {
|
||||
expect(prompt).toMatch(/read[\s\S]*?\.sisyphus\/plans\//)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,8 @@
|
||||
* - Extended reasoning sections
|
||||
*/
|
||||
|
||||
import { buildAntiDuplicationSection } from "../dynamic-agent-prompt-builder"
|
||||
|
||||
export const ATLAS_SYSTEM_PROMPT = `
|
||||
<identity>
|
||||
You are Atlas - the Master Orchestrator from OhMyOpenCode.
|
||||
@@ -24,6 +26,8 @@ Implementation tasks are the means. Final Wave approval is the goal.
|
||||
One task per delegation. Parallel when independent. Verify everything.
|
||||
</mission>
|
||||
|
||||
${buildAntiDuplicationSection()}
|
||||
|
||||
<delegation_system>
|
||||
## How to Delegate
|
||||
|
||||
@@ -100,6 +104,29 @@ Every \`task()\` prompt MUST include ALL 6 sections:
|
||||
**If your prompt is under 30 lines, it's TOO SHORT.**
|
||||
</delegation_system>
|
||||
|
||||
<auto_continue>
|
||||
## AUTO-CONTINUE POLICY (STRICT)
|
||||
|
||||
**CRITICAL: NEVER ask the user "should I continue", "proceed to next task", or any approval-style questions between plan steps.**
|
||||
|
||||
**You MUST auto-continue immediately after verification passes:**
|
||||
- After any delegation completes and passes verification → Immediately delegate next task
|
||||
- Do NOT wait for user input, do NOT ask "should I continue"
|
||||
- Only pause or ask if you are truly blocked by missing information, an external dependency, or a critical failure
|
||||
|
||||
**The only time you ask the user:**
|
||||
- Plan needs clarification or modification before execution
|
||||
- Blocked by an external dependency beyond your control
|
||||
- Critical failure prevents any further progress
|
||||
|
||||
**Auto-continue examples:**
|
||||
- Task A done → Verify → Pass → Immediately start Task B
|
||||
- Task fails → Retry 3x → Still fails → Document → Move to next independent task
|
||||
- NEVER: "Should I continue to the next task?"
|
||||
|
||||
**This is NOT optional. This is core to your role as orchestrator.**
|
||||
</auto_continue>
|
||||
|
||||
<workflow>
|
||||
## Step 0: Register Tracking
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
* - Consequence-driven framing (Gemini ignores soft warnings)
|
||||
*/
|
||||
|
||||
import { buildAntiDuplicationSection } from "../dynamic-agent-prompt-builder"
|
||||
|
||||
export const ATLAS_GEMINI_SYSTEM_PROMPT = `
|
||||
<identity>
|
||||
You are Atlas - Master Orchestrator from OhMyOpenCode.
|
||||
@@ -51,6 +53,8 @@ Implementation tasks are the means. Final Wave approval is the goal.
|
||||
- **Your creativity should go into ORCHESTRATION QUALITY, not implementation decisions.**
|
||||
</scope_and_design_constraints>
|
||||
|
||||
${buildAntiDuplicationSection()}
|
||||
|
||||
<delegation_system>
|
||||
## How to Delegate
|
||||
|
||||
@@ -117,6 +121,29 @@ Every \`task()\` prompt MUST include ALL 6 sections:
|
||||
**Minimum 30 lines per delegation prompt. Under 30 lines = the subagent WILL fail.**
|
||||
</delegation_system>
|
||||
|
||||
<auto_continue>
|
||||
## AUTO-CONTINUE POLICY (STRICT)
|
||||
|
||||
**CRITICAL: NEVER ask the user "should I continue", "proceed to next task", or any approval-style questions between plan steps.**
|
||||
|
||||
**You MUST auto-continue immediately after verification passes:**
|
||||
- After any delegation completes and passes verification → Immediately delegate next task
|
||||
- Do NOT wait for user input, do NOT ask "should I continue"
|
||||
- Only pause or ask if you are truly blocked by missing information, an external dependency, or a critical failure
|
||||
|
||||
**The only time you ask the user:**
|
||||
- Plan needs clarification or modification before execution
|
||||
- Blocked by an external dependency beyond your control
|
||||
- Critical failure prevents any further progress
|
||||
|
||||
**Auto-continue examples:**
|
||||
- Task A done → Verify → Pass → Immediately start Task B
|
||||
- Task fails → Retry 3x → Still fails → Document → Move to next independent task
|
||||
- NEVER: "Should I continue to the next task?"
|
||||
|
||||
**This is NOT optional. This is core to your role as orchestrator.**
|
||||
</auto_continue>
|
||||
|
||||
<workflow>
|
||||
## Step 0: Register Tracking
|
||||
|
||||
|
||||
+29
-1
@@ -8,6 +8,8 @@
|
||||
* - Scope discipline (no extra features)
|
||||
*/
|
||||
|
||||
import { buildAntiDuplicationSection } from "../dynamic-agent-prompt-builder"
|
||||
|
||||
export const ATLAS_GPT_SYSTEM_PROMPT = `
|
||||
<identity>
|
||||
You are Atlas - Master Orchestrator from OhMyOpenCode.
|
||||
@@ -40,9 +42,10 @@ Implementation tasks are the means. Final Wave approval is the goal.
|
||||
</scope_and_design_constraints>
|
||||
|
||||
<uncertainty_and_ambiguity>
|
||||
- If a task is ambiguous or underspecified:
|
||||
- During initial plan analysis, if a task is ambiguous or underspecified:
|
||||
- Ask 1-3 precise clarifying questions, OR
|
||||
- State your interpretation explicitly and proceed with the simplest approach.
|
||||
- Once execution has started, do NOT stop to ask for continuation or approval between steps.
|
||||
- Never fabricate task details, file paths, or requirements.
|
||||
- Prefer language like "Based on the plan..." instead of absolute claims.
|
||||
- When unsure about parallelization, default to sequential execution.
|
||||
@@ -60,6 +63,8 @@ Implementation tasks are the means. Final Wave approval is the goal.
|
||||
3. \`Read\` for changed files
|
||||
</tool_usage_rules>
|
||||
|
||||
${buildAntiDuplicationSection()}
|
||||
|
||||
<delegation_system>
|
||||
## Delegation API
|
||||
|
||||
@@ -126,6 +131,29 @@ Every \`task()\` prompt MUST include ALL 6 sections:
|
||||
**Minimum 30 lines per delegation prompt.**
|
||||
</delegation_system>
|
||||
|
||||
<auto_continue>
|
||||
## AUTO-CONTINUE POLICY (STRICT)
|
||||
|
||||
**CRITICAL: NEVER ask the user "should I continue", "proceed to next task", or any approval-style questions between plan steps.**
|
||||
|
||||
**You MUST auto-continue immediately after verification passes:**
|
||||
- After any delegation completes and passes verification → Immediately delegate next task
|
||||
- Do NOT wait for user input, do NOT ask "should I continue"
|
||||
- Only pause or ask if you are truly blocked by missing information, an external dependency, or a critical failure
|
||||
|
||||
**The only time you ask the user:**
|
||||
- Plan needs clarification or modification before execution
|
||||
- Blocked by an external dependency beyond your control
|
||||
- Critical failure prevents any further progress
|
||||
|
||||
**Auto-continue examples:**
|
||||
- Task A done → Verify → Pass → Immediately start Task B
|
||||
- Task fails → Retry 3x → Still fails → Document → Move to next independent task
|
||||
- NEVER: "Should I continue to the next task?"
|
||||
|
||||
**This is NOT optional. This is core to your role as orchestrator.**
|
||||
</auto_continue>
|
||||
|
||||
<workflow>
|
||||
## Step 0: Register Tracking
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createSisyphusAgent } from "./sisyphus"
|
||||
import { createHephaestusAgent } from "./hephaestus"
|
||||
import { buildSisyphusJuniorPrompt } from "./sisyphus-junior/agent"
|
||||
import {
|
||||
buildAntiDuplicationSection,
|
||||
buildExploreSection,
|
||||
type AvailableAgent,
|
||||
} from "./dynamic-agent-prompt-builder"
|
||||
|
||||
const exploreAgent = {
|
||||
name: "explore",
|
||||
description: "Contextual grep specialist",
|
||||
metadata: {
|
||||
category: "advisor",
|
||||
cost: "FREE",
|
||||
promptAlias: "Explore",
|
||||
triggers: [],
|
||||
useWhen: ["Multiple search angles needed"],
|
||||
avoidWhen: ["Single keyword search is enough"],
|
||||
},
|
||||
} satisfies AvailableAgent
|
||||
|
||||
describe("delegation trust prompt rules", () => {
|
||||
test("buildAntiDuplicationSection explains overlap is forbidden", () => {
|
||||
// given
|
||||
const section = buildAntiDuplicationSection()
|
||||
|
||||
// when / then
|
||||
expect(section).toContain("DO NOT perform the same search yourself")
|
||||
expect(section).toContain("non-overlapping work")
|
||||
expect(section).toContain("End your response")
|
||||
})
|
||||
|
||||
test("buildExploreSection includes delegation trust rule", () => {
|
||||
// given
|
||||
const agents = [exploreAgent]
|
||||
|
||||
// when
|
||||
const section = buildExploreSection(agents)
|
||||
|
||||
// then
|
||||
expect(section).toContain("Delegation Trust Rule")
|
||||
expect(section).toContain("do **not** manually perform that same search yourself")
|
||||
})
|
||||
|
||||
test("Sisyphus prompt forbids duplicate delegated exploration", () => {
|
||||
// given
|
||||
const agent = createSisyphusAgent("anthropic/claude-sonnet-4-6", [exploreAgent])
|
||||
|
||||
// when
|
||||
const prompt = agent.prompt
|
||||
|
||||
// then
|
||||
expect(prompt).toContain("Continue only with non-overlapping work")
|
||||
expect(prompt).toContain("DO NOT perform the same search yourself")
|
||||
})
|
||||
|
||||
test("Hephaestus prompt forbids duplicate delegated exploration", () => {
|
||||
// given
|
||||
const agent = createHephaestusAgent("openai/gpt-5.2", [exploreAgent])
|
||||
|
||||
// when
|
||||
const prompt = agent.prompt
|
||||
|
||||
// then
|
||||
expect(prompt).toContain("Continue only with non-overlapping work after launching background agents")
|
||||
expect(prompt).toContain("DO NOT perform the same search yourself")
|
||||
})
|
||||
|
||||
test("Hephaestus GPT-5.4 prompt forbids duplicate delegated exploration", () => {
|
||||
// given
|
||||
const agent = createHephaestusAgent("openai/gpt-5.4", [exploreAgent])
|
||||
|
||||
// when
|
||||
const prompt = agent.prompt
|
||||
|
||||
// then
|
||||
expect(prompt).toContain("continue only with non-overlapping work while they search")
|
||||
expect(prompt).toContain("Continue only with non-overlapping work after launching background agents")
|
||||
expect(prompt).toContain("DO NOT perform the same search yourself")
|
||||
})
|
||||
|
||||
test("Hephaestus GPT-5.3 Codex prompt forbids duplicate delegated exploration", () => {
|
||||
// given
|
||||
const agent = createHephaestusAgent("openai/gpt-5.3-codex", [exploreAgent])
|
||||
|
||||
// when
|
||||
const prompt = agent.prompt
|
||||
|
||||
// then
|
||||
expect(prompt).toContain("continue only with non-overlapping work while they search")
|
||||
expect(prompt).toContain("Continue only with non-overlapping work after launching background agents")
|
||||
expect(prompt).toContain("DO NOT perform the same search yourself")
|
||||
})
|
||||
|
||||
test("Sisyphus-Junior GPT prompt forbids duplicate delegated exploration", () => {
|
||||
// given
|
||||
const prompt = buildSisyphusJuniorPrompt("openai/gpt-5.2", false)
|
||||
|
||||
// when / then
|
||||
expect(prompt).toContain("continue only with non-overlapping work while they search")
|
||||
expect(prompt).toContain("DO NOT perform the same search yourself")
|
||||
})
|
||||
|
||||
test("Sisyphus GPT-5.4 prompt forbids duplicate delegated exploration", () => {
|
||||
// given
|
||||
const agent = createSisyphusAgent("openai/gpt-5.4", [exploreAgent])
|
||||
|
||||
// when
|
||||
const prompt = agent.prompt
|
||||
|
||||
// then
|
||||
expect(prompt).toContain("do only non-overlapping work simultaneously")
|
||||
expect(prompt).toContain("Continue only with non-overlapping work")
|
||||
expect(prompt).toContain("DO NOT perform the same search yourself")
|
||||
})
|
||||
|
||||
test("Sisyphus-Junior GPT-5.4 prompt forbids duplicate delegated exploration", () => {
|
||||
// given
|
||||
const prompt = buildSisyphusJuniorPrompt("openai/gpt-5.4", false)
|
||||
|
||||
// when / then
|
||||
expect(prompt).toContain("continue only with non-overlapping work while they search")
|
||||
expect(prompt).toContain("DO NOT perform the same search yourself")
|
||||
})
|
||||
|
||||
test("Sisyphus-Junior GPT-5.3 Codex prompt forbids duplicate delegated exploration", () => {
|
||||
// given
|
||||
const prompt = buildSisyphusJuniorPrompt("openai/gpt-5.3-codex", false)
|
||||
|
||||
// when / then
|
||||
expect(prompt).toContain("continue only with non-overlapping work while they search")
|
||||
expect(prompt).toContain("DO NOT perform the same search yourself")
|
||||
})
|
||||
|
||||
test("Sisyphus-Junior Gemini prompt forbids duplicate delegated exploration", () => {
|
||||
// given
|
||||
const prompt = buildSisyphusJuniorPrompt("google/gemini-3.1-pro", false)
|
||||
|
||||
// when / then
|
||||
expect(prompt).toContain("continue only with non-overlapping work while they search")
|
||||
expect(prompt).toContain("DO NOT perform the same search yourself")
|
||||
})
|
||||
})
|
||||
@@ -118,6 +118,8 @@ export function buildExploreSection(agents: AvailableAgent[]): string {
|
||||
|
||||
Use it as a **peer tool**, not a fallback. Fire liberally for discovery, not for files you already know.
|
||||
|
||||
**Delegation Trust Rule:** Once you fire an explore agent for a search, do **not** manually perform that same search yourself. Use direct tools only for non-overlapping work or when you intentionally skipped delegation.
|
||||
|
||||
**Use Direct Tools when:**
|
||||
${avoidWhen.map((w) => `- ${w}`).join("\n")}
|
||||
|
||||
@@ -335,6 +337,7 @@ export function buildAntiPatternsSection(): string {
|
||||
"- **Search**: Firing agents for single-line typos or obvious syntax errors",
|
||||
"- **Debugging**: Shotgun debugging, random changes",
|
||||
"- **Background Tasks**: Polling `background_output` on running tasks — end response and wait for notification",
|
||||
"- **Delegation Duplication**: Delegating exploration to explore/librarian and then manually doing the same search yourself",
|
||||
"- **Oracle**: Delivering answer without collecting Oracle results",
|
||||
]
|
||||
|
||||
@@ -470,3 +473,52 @@ export function buildUltraworkSection(
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
// Anti-duplication section for agent prompts
|
||||
export function buildAntiDuplicationSection(): string {
|
||||
return `<Anti_Duplication>
|
||||
## Anti-Duplication Rule (CRITICAL)
|
||||
|
||||
Once you delegate exploration to explore/librarian agents, **DO NOT perform the same search yourself**.
|
||||
|
||||
### What this means:
|
||||
|
||||
**FORBIDDEN:**
|
||||
- After firing explore/librarian, manually grep/search for the same information
|
||||
- Re-doing the research the agents were just tasked with
|
||||
- "Just quickly checking" the same files the background agents are checking
|
||||
|
||||
**ALLOWED:**
|
||||
- Continue with **non-overlapping work** — work that doesn't depend on the delegated research
|
||||
- Work on unrelated parts of the codebase
|
||||
- Preparation work (e.g., setting up files, configs) that can proceed independently
|
||||
|
||||
### Wait for Results Properly:
|
||||
|
||||
When you need the delegated results but they're not ready:
|
||||
|
||||
1. **End your response** — do NOT continue with work that depends on those results
|
||||
2. **Wait for the completion notification** — the system will trigger your next turn
|
||||
3. **Then** collect results via \`background_output(task_id="...")\`
|
||||
4. **Do NOT** impatiently re-search the same topics while waiting
|
||||
|
||||
### Why This Matters:
|
||||
|
||||
- **Wasted tokens**: Duplicate exploration wastes your context budget
|
||||
- **Confusion**: You might contradict the agent's findings
|
||||
- **Efficiency**: The whole point of delegation is parallel throughput
|
||||
|
||||
### Example:
|
||||
|
||||
\`\`\`typescript
|
||||
// WRONG: After delegating, re-doing the search
|
||||
task(subagent_type="explore", run_in_background=true, ...)
|
||||
// Then immediately grep for the same thing yourself — FORBIDDEN
|
||||
|
||||
// CORRECT: Continue non-overlapping work
|
||||
task(subagent_type="explore", run_in_background=true, ...)
|
||||
// Work on a different, unrelated file while they search
|
||||
// End your response and wait for the notification
|
||||
\`\`\`
|
||||
</Anti_Duplication>`
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
buildHardBlocksSection,
|
||||
buildAntiPatternsSection,
|
||||
buildToolCallFormatSection,
|
||||
buildAntiDuplicationSection,
|
||||
categorizeTools,
|
||||
} from "../dynamic-agent-prompt-builder";
|
||||
const MODE: AgentMode = "all";
|
||||
@@ -156,7 +157,7 @@ Asking the user is the LAST resort after exhausting creative alternatives.
|
||||
- Run verification (lint, tests, build) WITHOUT asking
|
||||
- Make decisions. Course-correct only on CONCRETE failure
|
||||
- Note assumptions in final message, not as questions mid-work
|
||||
- Need context? Fire explore/librarian in background IMMEDIATELY — keep working while they search
|
||||
- Need context? Fire explore/librarian in background IMMEDIATELY — continue only with non-overlapping work while they search
|
||||
- User asks "did you do X?" and you didn't → Acknowledge briefly, DO X immediately
|
||||
- User asks a question implying work → Answer briefly, DO the implied work in the same turn
|
||||
- You wrote a plan in your response → EXECUTE the plan before ending turn — plans are starting lines, not finish lines
|
||||
@@ -292,11 +293,13 @@ Prompt structure for each agent:
|
||||
- Fire 2-5 explore agents in parallel for any non-trivial codebase question
|
||||
- Parallelize independent file reads — don't read files one at a time
|
||||
- NEVER use \`run_in_background=false\` for explore/librarian
|
||||
- Continue your work immediately after launching background agents
|
||||
- Continue only with non-overlapping work after launching background agents
|
||||
- Collect results with \`background_output(task_id="...")\` when needed
|
||||
- BEFORE final answer, cancel DISPOSABLE tasks individually: \`background_cancel(taskId="bg_explore_xxx")\`, \`background_cancel(taskId="bg_librarian_xxx")\`
|
||||
- **NEVER use \`background_cancel(all=true)\`** — it kills tasks whose results you haven't collected yet
|
||||
|
||||
${buildAntiDuplicationSection()}
|
||||
|
||||
### Search Stop Conditions
|
||||
|
||||
STOP searching when:
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
buildOracleSection,
|
||||
buildHardBlocksSection,
|
||||
buildAntiPatternsSection,
|
||||
buildAntiDuplicationSection,
|
||||
} from "../dynamic-agent-prompt-builder";
|
||||
|
||||
function buildTodoDisciplineSection(useTaskSystem: boolean): string {
|
||||
@@ -115,7 +116,7 @@ When blocked: try a different approach → decompose the problem → challenge a
|
||||
- Run verification (lint, tests, build) WITHOUT asking
|
||||
- Make decisions. Course-correct only on CONCRETE failure
|
||||
- Note assumptions in final message, not as questions mid-work
|
||||
- Need context? Fire explore/librarian in background IMMEDIATELY — keep working while they search
|
||||
- Need context? Fire explore/librarian in background IMMEDIATELY — continue only with non-overlapping work while they search
|
||||
- User asks "did you do X?" and you didn't → Acknowledge briefly, DO X immediately
|
||||
- User asks a question implying work → Answer briefly, DO the implied work in the same turn
|
||||
- You wrote a plan in your response → EXECUTE the plan before ending turn — plans are starting lines, not finish lines
|
||||
@@ -241,11 +242,13 @@ Prompt structure for each agent:
|
||||
- Fire 2-5 explore agents in parallel for any non-trivial codebase question
|
||||
- Parallelize independent file reads — don't read files one at a time
|
||||
- NEVER use \`run_in_background=false\` for explore/librarian
|
||||
- Continue your work immediately after launching background agents
|
||||
- Continue only with non-overlapping work after launching background agents
|
||||
- Collect results with \`background_output(task_id="...")\` when needed
|
||||
- BEFORE final answer, cancel DISPOSABLE tasks individually: \`background_cancel(taskId="bg_explore_xxx")\`, \`background_cancel(taskId="bg_librarian_xxx")\`
|
||||
- **NEVER use \`background_cancel(all=true)\`** — it kills tasks whose results you haven't collected yet
|
||||
|
||||
${buildAntiDuplicationSection()}
|
||||
|
||||
### Search Stop Conditions
|
||||
|
||||
STOP searching when you have enough context, the same information keeps appearing, 2 search iterations yielded nothing new, or a direct answer was found. Do not over-explore.
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
buildOracleSection,
|
||||
buildHardBlocksSection,
|
||||
buildAntiPatternsSection,
|
||||
buildAntiDuplicationSection,
|
||||
} from "../dynamic-agent-prompt-builder";
|
||||
|
||||
function buildTodoDisciplineSection(useTaskSystem: boolean): string {
|
||||
@@ -109,7 +110,7 @@ Asking the user is the LAST resort after exhausting creative alternatives.
|
||||
- Run verification (lint, tests, build) WITHOUT asking
|
||||
- Make decisions. Course-correct only on CONCRETE failure
|
||||
- Note assumptions in final message, not as questions mid-work
|
||||
- Need context? Fire explore/librarian in background IMMEDIATELY — keep working while they search
|
||||
- Need context? Fire explore/librarian in background IMMEDIATELY — continue only with non-overlapping work while they search
|
||||
|
||||
## Hard Constraints
|
||||
|
||||
@@ -194,11 +195,13 @@ task(subagent_type="librarian", run_in_background=true, load_skills=[], descript
|
||||
- Fire 2-5 explore agents in parallel for any non-trivial codebase question
|
||||
- Parallelize independent file reads — don't read files one at a time
|
||||
- NEVER use \`run_in_background=false\` for explore/librarian
|
||||
- Continue your work immediately after launching background agents
|
||||
- Continue only with non-overlapping work after launching background agents
|
||||
- Collect results with \`background_output(task_id="...")\` when needed
|
||||
- BEFORE final answer, cancel DISPOSABLE tasks individually
|
||||
- **NEVER use \`background_cancel(all=true)\`**
|
||||
|
||||
${buildAntiDuplicationSection()}
|
||||
|
||||
### Search Stop Conditions
|
||||
|
||||
STOP searching when:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentMode, AgentPromptMetadata } from "./types"
|
||||
import { buildAntiDuplicationSection } from "./dynamic-agent-prompt-builder"
|
||||
import { createAgentToolRestrictions } from "../shared/permission-compat"
|
||||
|
||||
const MODE: AgentMode = "subagent"
|
||||
@@ -25,6 +26,8 @@ export const METIS_SYSTEM_PROMPT = `# Metis - Pre-Planning Consultant
|
||||
- **READ-ONLY**: You analyze, question, advise. You do NOT implement or modify files.
|
||||
- **OUTPUT**: Your analysis feeds into Prometheus (planner). Be actionable.
|
||||
|
||||
${buildAntiDuplicationSection()}
|
||||
|
||||
---
|
||||
|
||||
## PHASE 0: INTENT CLASSIFICATION (MANDATORY FIRST STEP)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { PROMETHEUS_SYSTEM_PROMPT } from "./prometheus"
|
||||
import { PROMETHEUS_GPT_SYSTEM_PROMPT } from "./prometheus/gpt"
|
||||
import { PROMETHEUS_GEMINI_SYSTEM_PROMPT } from "./prometheus/gemini"
|
||||
|
||||
describe("PROMETHEUS_SYSTEM_PROMPT Momus invocation policy", () => {
|
||||
test("should direct providing ONLY the file path string when invoking Momus", () => {
|
||||
@@ -82,3 +84,22 @@ describe("PROMETHEUS_SYSTEM_PROMPT zero human intervention", () => {
|
||||
expect(lowerPrompt).toMatch(/zero acceptance criteria require human/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Prometheus prompts anti-duplication coverage", () => {
|
||||
test("all variants should include anti-duplication rules for delegated exploration", () => {
|
||||
// given
|
||||
const prompts = [
|
||||
PROMETHEUS_SYSTEM_PROMPT,
|
||||
PROMETHEUS_GPT_SYSTEM_PROMPT,
|
||||
PROMETHEUS_GEMINI_SYSTEM_PROMPT,
|
||||
]
|
||||
|
||||
// when / then
|
||||
for (const prompt of prompts) {
|
||||
expect(prompt).toContain("<Anti_Duplication>")
|
||||
expect(prompt).toContain("Anti-Duplication Rule")
|
||||
expect(prompt).toContain("DO NOT perform the same search yourself")
|
||||
expect(prompt).toContain("non-overlapping work")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
* - Tool-call mandate for every phase transition
|
||||
*/
|
||||
|
||||
import { buildAntiDuplicationSection } from "../dynamic-agent-prompt-builder"
|
||||
|
||||
export const PROMETHEUS_GEMINI_SYSTEM_PROMPT = `
|
||||
<identity>
|
||||
You are Prometheus - Strategic Planning Consultant from OhMyOpenCode.
|
||||
@@ -43,6 +45,8 @@ A plan is "decision complete" when the implementer needs ZERO judgment calls —
|
||||
This is your north star quality metric.
|
||||
</mission>
|
||||
|
||||
${buildAntiDuplicationSection()}
|
||||
|
||||
<core_principles>
|
||||
## Three Principles
|
||||
|
||||
@@ -325,4 +329,4 @@ You are Prometheus, the strategic planning consultant. You bring foresight and s
|
||||
|
||||
export function getGeminiPrometheusPrompt(): string {
|
||||
return PROMETHEUS_GEMINI_SYSTEM_PROMPT
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
* - Principle-driven: Decision Complete, Explore Before Asking, Two Kinds of Unknowns
|
||||
*/
|
||||
|
||||
import { buildAntiDuplicationSection } from "../dynamic-agent-prompt-builder";
|
||||
|
||||
export const PROMETHEUS_GPT_SYSTEM_PROMPT = `
|
||||
<identity>
|
||||
You are Prometheus - Strategic Planning Consultant from OhMyOpenCode.
|
||||
@@ -25,6 +27,8 @@ A plan is "decision complete" when the implementer needs ZERO judgment calls —
|
||||
This is your north star quality metric.
|
||||
</mission>
|
||||
|
||||
${buildAntiDuplicationSection()}
|
||||
|
||||
<core_principles>
|
||||
## Three Principles (Read First)
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* Includes intent classification, research patterns, and anti-patterns.
|
||||
*/
|
||||
|
||||
import { buildAntiDuplicationSection } from "../dynamic-agent-prompt-builder"
|
||||
|
||||
export const PROMETHEUS_INTERVIEW_MODE = `# PHASE 1: INTERVIEW MODE (DEFAULT)
|
||||
|
||||
## Step 0: Intent Classification (EVERY request)
|
||||
@@ -29,6 +31,8 @@ Before diving into consultation, classify the work intent. This determines your
|
||||
- **Simple** (1-2 files, clear scope, <30 min work) — **Lightweight**: 1-2 targeted questions → propose approach.
|
||||
- **Complex** (3+ files, multiple components, architectural impact) — **Full consultation**: Intent-specific deep interview.
|
||||
|
||||
${buildAntiDuplicationSection()}
|
||||
|
||||
---
|
||||
|
||||
## Intent-Specific Interview Strategies
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
import { resolvePromptAppend } from "../builtin-agents/resolve-file-uri"
|
||||
import { buildAntiDuplicationSection } from "../dynamic-agent-prompt-builder"
|
||||
|
||||
export function buildDefaultSisyphusJuniorPrompt(
|
||||
useTaskSystem: boolean,
|
||||
@@ -23,6 +24,8 @@ Sisyphus-Junior - Focused executor from OhMyOpenCode.
|
||||
Execute tasks directly.
|
||||
</Role>
|
||||
|
||||
${buildAntiDuplicationSection()}
|
||||
|
||||
${todoDiscipline}
|
||||
|
||||
<Verification>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
|
||||
import { resolvePromptAppend } from "../builtin-agents/resolve-file-uri"
|
||||
import { buildAntiDuplicationSection } from "../dynamic-agent-prompt-builder"
|
||||
|
||||
export function buildGeminiSisyphusJuniorPrompt(
|
||||
useTaskSystem: boolean,
|
||||
@@ -58,7 +59,7 @@ Before responding, ask yourself: What tools do I need to call? What am I assumin
|
||||
- Run verification (lint, tests, build) WITHOUT asking
|
||||
- Make decisions. Course-correct only on CONCRETE failure
|
||||
- Note assumptions in final message, not as questions mid-work
|
||||
- Need context? Fire explore/librarian via call_omo_agent IMMEDIATELY — keep working while they search
|
||||
- Need context? Fire explore/librarian via call_omo_agent IMMEDIATELY — continue only with non-overlapping work while they search
|
||||
|
||||
## Scope Discipline
|
||||
|
||||
@@ -77,13 +78,15 @@ Before responding, ask yourself: What tools do I need to call? What am I assumin
|
||||
|
||||
<tool_usage_rules>
|
||||
- Parallelize independent tool calls: multiple file reads, grep searches, agent fires — all at once
|
||||
- Explore/Librarian via call_omo_agent = background research. Fire them and keep working
|
||||
- Explore/Librarian via call_omo_agent = background research. Fire them and continue only with non-overlapping work
|
||||
- After any file edit: restate what changed, where, and what validation follows
|
||||
- Prefer tools over guessing whenever you need specific data (files, configs, patterns)
|
||||
- ALWAYS use tools over internal knowledge for file contents, project state, and verification
|
||||
- **DO NOT SKIP tool calls because you think you already know the answer. You DON'T.**
|
||||
</tool_usage_rules>
|
||||
|
||||
${buildAntiDuplicationSection()}
|
||||
|
||||
${taskDiscipline}
|
||||
|
||||
## Progress Updates
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import { resolvePromptAppend } from "../builtin-agents/resolve-file-uri"
|
||||
import { buildAntiDuplicationSection } from "../dynamic-agent-prompt-builder"
|
||||
|
||||
export function buildGpt53CodexSisyphusJuniorPrompt(
|
||||
useTaskSystem: boolean,
|
||||
@@ -40,7 +41,7 @@ When blocked: try a different approach → decompose the problem → challenge a
|
||||
- Run verification (lint, tests, build) WITHOUT asking
|
||||
- Make decisions. Course-correct only on CONCRETE failure
|
||||
- Note assumptions in final message, not as questions mid-work
|
||||
- Need context? Fire explore/librarian via call_omo_agent IMMEDIATELY — keep working while they search
|
||||
- Need context? Fire explore/librarian via call_omo_agent IMMEDIATELY — continue only with non-overlapping work while they search
|
||||
|
||||
## Scope Discipline
|
||||
|
||||
@@ -58,12 +59,14 @@ When blocked: try a different approach → decompose the problem → challenge a
|
||||
|
||||
<tool_usage_rules>
|
||||
- Parallelize independent tool calls: multiple file reads, grep searches, agent fires — all at once
|
||||
- Explore/Librarian via call_omo_agent = background research. Fire them and keep working
|
||||
- Explore/Librarian via call_omo_agent = background research. Fire them and continue only with non-overlapping work
|
||||
- After any file edit: restate what changed, where, and what validation follows
|
||||
- Prefer tools over guessing whenever you need specific data (files, configs, patterns)
|
||||
- ALWAYS use tools over internal knowledge for file contents, project state, and verification
|
||||
</tool_usage_rules>
|
||||
|
||||
${buildAntiDuplicationSection()}
|
||||
|
||||
${taskDiscipline}
|
||||
|
||||
## Progress Updates
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
|
||||
import { resolvePromptAppend } from "../builtin-agents/resolve-file-uri";
|
||||
import { buildAntiDuplicationSection } from "../dynamic-agent-prompt-builder";
|
||||
|
||||
export function buildGpt54SisyphusJuniorPrompt(
|
||||
useTaskSystem: boolean,
|
||||
@@ -43,7 +44,7 @@ When blocked: try a different approach → decompose the problem → challenge a
|
||||
- Run verification (lint, tests, build) WITHOUT asking
|
||||
- Make decisions. Course-correct only on CONCRETE failure
|
||||
- Note assumptions in final message, not as questions mid-work
|
||||
- Need context? Fire explore/librarian via call_omo_agent IMMEDIATELY — keep working while they search
|
||||
- Need context? Fire explore/librarian via call_omo_agent IMMEDIATELY — continue only with non-overlapping work while they search
|
||||
|
||||
## Scope Discipline
|
||||
|
||||
@@ -62,12 +63,14 @@ When blocked: try a different approach → decompose the problem → challenge a
|
||||
|
||||
<tool_usage_rules>
|
||||
- Parallelize independent tool calls: multiple file reads, grep searches, agent fires — all at once
|
||||
- Explore/Librarian via call_omo_agent = background research. Fire them and keep working
|
||||
- Explore/Librarian via call_omo_agent = background research. Fire them and continue only with non-overlapping work
|
||||
- After any file edit: restate what changed, where, and what validation follows
|
||||
- Prefer tools over guessing whenever you need specific data (files, configs, patterns)
|
||||
- ALWAYS use tools over internal knowledge for file contents, project state, and verification
|
||||
</tool_usage_rules>
|
||||
|
||||
${buildAntiDuplicationSection()}
|
||||
|
||||
${taskDiscipline}
|
||||
|
||||
## Progress Updates
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
import { resolvePromptAppend } from "../builtin-agents/resolve-file-uri"
|
||||
import { buildAntiDuplicationSection } from "../dynamic-agent-prompt-builder"
|
||||
|
||||
export function buildGptSisyphusJuniorPrompt(
|
||||
useTaskSystem: boolean,
|
||||
@@ -41,7 +42,7 @@ When blocked: try a different approach → decompose the problem → challenge a
|
||||
- Run verification (lint, tests, build) WITHOUT asking
|
||||
- Make decisions. Course-correct only on CONCRETE failure
|
||||
- Note assumptions in final message, not as questions mid-work
|
||||
- Need context? Fire explore/librarian via call_omo_agent IMMEDIATELY — keep working while they search
|
||||
- Need context? Fire explore/librarian via call_omo_agent IMMEDIATELY — continue only with non-overlapping work while they search
|
||||
|
||||
## Scope Discipline
|
||||
|
||||
@@ -59,12 +60,14 @@ When blocked: try a different approach → decompose the problem → challenge a
|
||||
|
||||
<tool_usage_rules>
|
||||
- Parallelize independent tool calls: multiple file reads, grep searches, agent fires — all at once
|
||||
- Explore/Librarian via call_omo_agent = background research. Fire them and keep working
|
||||
- Explore/Librarian via call_omo_agent = background research. Fire them and continue only with non-overlapping work
|
||||
- After any file edit: restate what changed, where, and what validation follows
|
||||
- Prefer tools over guessing whenever you need specific data (files, configs, patterns)
|
||||
- ALWAYS use tools over internal knowledge for file contents, project state, and verification
|
||||
</tool_usage_rules>
|
||||
|
||||
${buildAntiDuplicationSection()}
|
||||
|
||||
${taskDiscipline}
|
||||
|
||||
## Progress Updates
|
||||
|
||||
+10
-5
@@ -37,6 +37,7 @@ import {
|
||||
buildAntiPatternsSection,
|
||||
buildParallelDelegationSection,
|
||||
buildNonClaudePlannerSection,
|
||||
buildAntiDuplicationSection,
|
||||
categorizeTools,
|
||||
} from "./dynamic-agent-prompt-builder";
|
||||
|
||||
@@ -225,17 +226,21 @@ task(subagent_type="explore", run_in_background=true, load_skills=[], descriptio
|
||||
// Reference Grep (external)
|
||||
task(subagent_type="librarian", run_in_background=true, load_skills=[], description="Find JWT security docs", prompt="I'm implementing JWT auth and need current security best practices to choose token storage (httpOnly cookies vs localStorage) and set expiration policy. Find: OWASP auth guidelines, recommended token lifetimes, refresh token rotation strategies, common JWT vulnerabilities. Skip 'what is JWT' tutorials — production security guidance only.")
|
||||
task(subagent_type="librarian", run_in_background=true, load_skills=[], description="Find Express auth patterns", prompt="I'm building Express auth middleware and need production-quality patterns to structure my middleware chain. Find how established Express apps (1000+ stars) handle: middleware ordering, token refresh, role-based access control, auth error propagation. Skip basic tutorials — I need battle-tested patterns with proper error handling.")
|
||||
// Continue only with non-overlapping work. If none exists, end your response and wait for completion.
|
||||
// WRONG: Sequential or blocking
|
||||
result = task(..., run_in_background=false) // Never wait synchronously for explore/librarian
|
||||
\`\`\`
|
||||
|
||||
### Background Result Collection:
|
||||
1. Launch parallel agents \u2192 receive task_ids
|
||||
2. If you have DIFFERENT independent work \u2192 do it now
|
||||
3. Otherwise \u2192 **END YOUR RESPONSE.**
|
||||
4. System sends \`<system-reminder>\` on completion \u2192 triggers your next turn
|
||||
5. Collect via \`background_output(task_id="...")\`
|
||||
6. Cleanup: Cancel disposable tasks individually via \`background_cancel(taskId="...")\`
|
||||
2. Continue only with non-overlapping work
|
||||
- If you have DIFFERENT independent work \u2192 do it now
|
||||
- Otherwise \u2192 **END YOUR RESPONSE.**
|
||||
3. System sends \`<system-reminder>\` on each task completion — then call \`background_output(task_id="...")\`
|
||||
4. Need results not yet ready? **End your response.** The notification will trigger your next turn.
|
||||
5. Cleanup: Cancel disposable tasks individually via \`background_cancel(taskId="...")\`
|
||||
|
||||
${buildAntiDuplicationSection()}
|
||||
|
||||
### Search Stop Conditions
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
buildAntiPatternsSection,
|
||||
buildParallelDelegationSection,
|
||||
buildNonClaudePlannerSection,
|
||||
buildAntiDuplicationSection,
|
||||
categorizeTools,
|
||||
} from "../dynamic-agent-prompt-builder";
|
||||
|
||||
@@ -319,7 +320,7 @@ task(subagent_type="explore", run_in_background=true, load_skills=[], descriptio
|
||||
// Reference Grep (external)
|
||||
task(subagent_type="librarian", run_in_background=true, load_skills=[], description="Find JWT security docs", prompt="I'm implementing JWT auth and need current security best practices to choose token storage (httpOnly cookies vs localStorage) and set expiration policy. Find: OWASP auth guidelines, recommended token lifetimes, refresh token rotation strategies, common JWT vulnerabilities. Skip 'what is JWT' tutorials — production security guidance only.")
|
||||
task(subagent_type="librarian", run_in_background=true, load_skills=[], description="Find Express auth patterns", prompt="I'm building Express auth middleware and need production-quality patterns to structure my middleware chain. Find how established Express apps (1000+ stars) handle: middleware ordering, token refresh, role-based access control, auth error propagation. Skip basic tutorials — I need battle-tested patterns with proper error handling.")
|
||||
// Continue working immediately. System notifies on completion — collect with background_output then.
|
||||
// Continue only with non-overlapping work. If none exists, end your response and wait for completion.
|
||||
|
||||
// WRONG: Sequential or blocking
|
||||
result = task(..., run_in_background=false) // Never wait synchronously for explore/librarian
|
||||
@@ -327,11 +328,14 @@ result = task(..., run_in_background=false) // Never wait synchronously for exp
|
||||
|
||||
### Background Result Collection:
|
||||
1. Launch parallel agents → receive task_ids
|
||||
2. If you have DIFFERENT independent work → do it now
|
||||
3. Otherwise → **END YOUR RESPONSE.**
|
||||
4. System sends \`<system-reminder>\` on completion → triggers your next turn
|
||||
5. Collect via \`background_output(task_id="...")\`
|
||||
6. Cleanup: Cancel disposable tasks individually via \`background_cancel(taskId="...")\`
|
||||
2. Continue only with non-overlapping work
|
||||
- If you have DIFFERENT independent work → do it now
|
||||
- Otherwise → **END YOUR RESPONSE.**
|
||||
3. System sends \`<system-reminder>\` on completion → triggers your next turn
|
||||
4. Collect via \`background_output(task_id="...")\`
|
||||
5. Cleanup: Cancel disposable tasks individually via \`background_cancel(taskId="...")\`
|
||||
|
||||
${buildAntiDuplicationSection()}
|
||||
|
||||
### Search Stop Conditions
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
buildOracleSection,
|
||||
buildHardBlocksSection,
|
||||
buildAntiPatternsSection,
|
||||
buildAntiDuplicationSection,
|
||||
buildNonClaudePlannerSection,
|
||||
categorizeTools,
|
||||
} from "../dynamic-agent-prompt-builder";
|
||||
@@ -233,7 +234,7 @@ ${librarianSection}
|
||||
<tool_method>
|
||||
- Fire 2-5 explore/librarian agents in parallel for any non-trivial codebase question.
|
||||
- Parallelize independent file reads — NEVER read files one at a time when you know multiple paths.
|
||||
- When delegating AND doing direct work: do both simultaneously.
|
||||
- When delegating AND doing direct work: do only non-overlapping work simultaneously.
|
||||
</tool_method>
|
||||
|
||||
Explore and Librarian agents are background grep — always \`run_in_background=true\`, always parallel.
|
||||
@@ -246,11 +247,14 @@ Each agent prompt should include:
|
||||
|
||||
Background result collection:
|
||||
1. Launch parallel agents → receive task_ids
|
||||
2. If you have DIFFERENT independent work → do it now
|
||||
3. Otherwise → **END YOUR RESPONSE.**
|
||||
4. System sends \`<system-reminder>\` on completion → triggers your next turn
|
||||
5. Collect via \`background_output(task_id="...")\`
|
||||
6. Cancel disposable tasks individually via \`background_cancel(taskId="...")\`
|
||||
2. Continue only with non-overlapping work
|
||||
- If you have DIFFERENT independent work → do it now
|
||||
- Otherwise → **END YOUR RESPONSE.**
|
||||
3. System sends \`<system-reminder>\` on completion → triggers your next turn
|
||||
4. Collect via \`background_output(task_id="...")\`
|
||||
5. Cancel disposable tasks individually via \`background_cancel(taskId="...")\`
|
||||
|
||||
${buildAntiDuplicationSection()}
|
||||
|
||||
Stop searching when: you have enough context, same info repeating, 2 iterations with no new data, or direct answer found.
|
||||
</explore>`;
|
||||
|
||||
@@ -69,6 +69,7 @@ program
|
||||
.passThroughOptions()
|
||||
.description("Run opencode with todo/background task completion enforcement")
|
||||
.option("-a, --agent <name>", "Agent to use (default: from CLI/env/config, fallback: Sisyphus)")
|
||||
.option("-m, --model <provider/model>", "Model override (e.g., anthropic/claude-sonnet-4)")
|
||||
.option("-d, --directory <path>", "Working directory")
|
||||
.option("-p, --port <port>", "Server port (attaches if port already in use)", parseInt)
|
||||
.option("--attach <url>", "Attach to existing opencode server URL")
|
||||
@@ -86,6 +87,8 @@ Examples:
|
||||
$ bunx oh-my-opencode run --json "Fix the bug" | jq .sessionId
|
||||
$ bunx oh-my-opencode run --on-complete "notify-send Done" "Fix the bug"
|
||||
$ bunx oh-my-opencode run --session-id ses_abc123 "Continue the work"
|
||||
$ bunx oh-my-opencode run --model anthropic/claude-sonnet-4 "Fix the bug"
|
||||
$ bunx oh-my-opencode run --agent Sisyphus --model openai/gpt-5.4 "Implement feature X"
|
||||
|
||||
Agent resolution order:
|
||||
1) --agent flag
|
||||
@@ -108,6 +111,7 @@ Unlike 'opencode run', this command waits until:
|
||||
const runOptions: RunOptions = {
|
||||
message,
|
||||
agent: options.agent,
|
||||
model: options.model,
|
||||
directory: options.directory,
|
||||
port: options.port,
|
||||
attach: options.attach,
|
||||
|
||||
@@ -1,10 +1,37 @@
|
||||
import { beforeEach, afterEach, describe, expect, it, spyOn } from "bun:test"
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import * as fs from "node:fs"
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, jest, spyOn } from "bun:test"
|
||||
|
||||
import * as dataPath from "../../shared/data-path"
|
||||
import * as logger from "../../shared/logger"
|
||||
import * as spawnHelpers from "../../shared/spawn-with-windows-hide"
|
||||
import type { BunInstallResult } from "./bun-install"
|
||||
import { runBunInstallWithDetails } from "./bun-install"
|
||||
|
||||
type CreateProcOptions = {
|
||||
exitCode?: number | null
|
||||
exited?: Promise<number>
|
||||
kill?: () => void
|
||||
output?: {
|
||||
stdout?: string
|
||||
stderr?: string
|
||||
}
|
||||
}
|
||||
|
||||
function createProc(options: CreateProcOptions = {}): ReturnType<typeof spawnHelpers.spawnWithWindowsHide> {
|
||||
const exitCode = options.exitCode ?? 0
|
||||
|
||||
return {
|
||||
exited: options.exited ?? Promise.resolve(exitCode),
|
||||
exitCode,
|
||||
stdout: options.output?.stdout !== undefined ? new Blob([options.output.stdout]).stream() : undefined,
|
||||
stderr: options.output?.stderr !== undefined ? new Blob([options.output.stderr]).stream() : undefined,
|
||||
kill: options.kill ?? (() => {}),
|
||||
} satisfies ReturnType<typeof spawnHelpers.spawnWithWindowsHide>
|
||||
}
|
||||
|
||||
describe("runBunInstallWithDetails", () => {
|
||||
let getOpenCodeCacheDirSpy: ReturnType<typeof spyOn>
|
||||
let logSpy: ReturnType<typeof spyOn>
|
||||
@@ -14,11 +41,7 @@ describe("runBunInstallWithDetails", () => {
|
||||
beforeEach(() => {
|
||||
getOpenCodeCacheDirSpy = spyOn(dataPath, "getOpenCodeCacheDir").mockReturnValue("/tmp/opencode-cache")
|
||||
logSpy = spyOn(logger, "log").mockImplementation(() => {})
|
||||
spawnWithWindowsHideSpy = spyOn(spawnHelpers, "spawnWithWindowsHide").mockReturnValue({
|
||||
exited: Promise.resolve(0),
|
||||
exitCode: 0,
|
||||
kill: () => {},
|
||||
} as ReturnType<typeof spawnHelpers.spawnWithWindowsHide>)
|
||||
spawnWithWindowsHideSpy = spyOn(spawnHelpers, "spawnWithWindowsHide").mockReturnValue(createProc())
|
||||
existsSyncSpy = spyOn(fs, "existsSync").mockReturnValue(true)
|
||||
})
|
||||
|
||||
@@ -29,15 +52,119 @@ describe("runBunInstallWithDetails", () => {
|
||||
existsSyncSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("runs bun install in the OpenCode cache directory", async () => {
|
||||
const result = await runBunInstallWithDetails()
|
||||
describe("#given the cache workspace exists", () => {
|
||||
describe("#when bun install uses inherited output", () => {
|
||||
it("#then runs bun install in the cache directory", async () => {
|
||||
// given
|
||||
|
||||
expect(result).toEqual({ success: true })
|
||||
expect(getOpenCodeCacheDirSpy).toHaveBeenCalledTimes(1)
|
||||
expect(spawnWithWindowsHideSpy).toHaveBeenCalledWith(["bun", "install"], {
|
||||
cwd: "/tmp/opencode-cache",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
// when
|
||||
const result = await runBunInstallWithDetails()
|
||||
|
||||
// then
|
||||
expect(result).toEqual({ success: true })
|
||||
expect(getOpenCodeCacheDirSpy).toHaveBeenCalledTimes(1)
|
||||
expect(spawnWithWindowsHideSpy).toHaveBeenCalledWith(["bun", "install"], {
|
||||
cwd: "/tmp/opencode-cache",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when bun install uses piped output", () => {
|
||||
it("#then passes pipe mode to the spawned process", async () => {
|
||||
// given
|
||||
|
||||
// when
|
||||
const result = await runBunInstallWithDetails({ outputMode: "pipe" })
|
||||
|
||||
// then
|
||||
expect(result).toEqual({ success: true })
|
||||
expect(spawnWithWindowsHideSpy).toHaveBeenCalledWith(["bun", "install"], {
|
||||
cwd: "/tmp/opencode-cache",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when piped bun install fails", () => {
|
||||
it("#then logs captured stdout and stderr", async () => {
|
||||
// given
|
||||
spawnWithWindowsHideSpy.mockReturnValue(
|
||||
createProc({
|
||||
exitCode: 1,
|
||||
output: {
|
||||
stdout: "resolved 10 packages",
|
||||
stderr: "network error",
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
// when
|
||||
const result = await runBunInstallWithDetails({ outputMode: "pipe" })
|
||||
|
||||
// then
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: "bun install failed with exit code 1",
|
||||
})
|
||||
expect(logSpy).toHaveBeenCalledWith("[bun-install] Captured output from failed bun install", {
|
||||
stdout: "resolved 10 packages",
|
||||
stderr: "network error",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when the install times out and proc.exited never resolves", () => {
|
||||
it("#then returns timedOut true without hanging", async () => {
|
||||
// given
|
||||
jest.useFakeTimers()
|
||||
|
||||
let killCallCount = 0
|
||||
spawnWithWindowsHideSpy.mockReturnValue(
|
||||
createProc({
|
||||
exitCode: null,
|
||||
exited: new Promise<number>(() => {}),
|
||||
kill: () => {
|
||||
killCallCount += 1
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
// when
|
||||
const resultPromise = runBunInstallWithDetails({ outputMode: "pipe" })
|
||||
jest.advanceTimersByTime(60_000)
|
||||
jest.runOnlyPendingTimers()
|
||||
await Promise.resolve()
|
||||
|
||||
const outcome = await Promise.race([
|
||||
resultPromise.then((result) => ({
|
||||
status: "resolved" as const,
|
||||
result,
|
||||
})),
|
||||
new Promise<{ status: "pending" }>((resolve) => {
|
||||
queueMicrotask(() => resolve({ status: "pending" }))
|
||||
}),
|
||||
])
|
||||
|
||||
// then
|
||||
if (outcome.status === "pending") {
|
||||
throw new Error("runBunInstallWithDetails did not resolve after timing out")
|
||||
}
|
||||
|
||||
expect(outcome.result).toEqual({
|
||||
success: false,
|
||||
timedOut: true,
|
||||
error: 'bun install timed out after 60 seconds. Try running manually: cd "/tmp/opencode-cache" && bun i',
|
||||
} satisfies BunInstallResult)
|
||||
expect(killCallCount).toBe(1)
|
||||
} finally {
|
||||
jest.clearAllTimers()
|
||||
jest.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { existsSync } from "node:fs"
|
||||
|
||||
import { getOpenCodeCacheDir } from "../../shared/data-path"
|
||||
import { log } from "../../shared/logger"
|
||||
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
|
||||
@@ -6,6 +7,26 @@ import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
|
||||
const BUN_INSTALL_TIMEOUT_SECONDS = 60
|
||||
const BUN_INSTALL_TIMEOUT_MS = BUN_INSTALL_TIMEOUT_SECONDS * 1000
|
||||
|
||||
type BunInstallOutputMode = "inherit" | "pipe"
|
||||
|
||||
interface RunBunInstallOptions {
|
||||
outputMode?: BunInstallOutputMode
|
||||
}
|
||||
|
||||
interface BunInstallOutput {
|
||||
stdout: string
|
||||
stderr: string
|
||||
}
|
||||
|
||||
declare function setTimeout(callback: () => void, delay?: number): number
|
||||
declare function clearTimeout(timeout: number): void
|
||||
|
||||
type ProcessOutputStream = ReturnType<typeof spawnWithWindowsHide>["stdout"]
|
||||
|
||||
declare const Bun: {
|
||||
readableStreamToText(stream: NonNullable<ProcessOutputStream>): Promise<string>
|
||||
}
|
||||
|
||||
export interface BunInstallResult {
|
||||
success: boolean
|
||||
timedOut?: boolean
|
||||
@@ -17,7 +38,33 @@ export async function runBunInstall(): Promise<boolean> {
|
||||
return result.success
|
||||
}
|
||||
|
||||
export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
|
||||
function readProcessOutput(stream: ProcessOutputStream): Promise<string> {
|
||||
if (!stream) {
|
||||
return Promise.resolve("")
|
||||
}
|
||||
|
||||
return Bun.readableStreamToText(stream)
|
||||
}
|
||||
|
||||
function logCapturedOutputOnFailure(outputMode: BunInstallOutputMode, output: BunInstallOutput): void {
|
||||
if (outputMode !== "pipe") {
|
||||
return
|
||||
}
|
||||
|
||||
const stdout = output.stdout.trim()
|
||||
const stderr = output.stderr.trim()
|
||||
if (!stdout && !stderr) {
|
||||
return
|
||||
}
|
||||
|
||||
log("[bun-install] Captured output from failed bun install", {
|
||||
stdout,
|
||||
stderr,
|
||||
})
|
||||
}
|
||||
|
||||
export async function runBunInstallWithDetails(options?: RunBunInstallOptions): Promise<BunInstallResult> {
|
||||
const outputMode = options?.outputMode ?? "inherit"
|
||||
const cacheDir = getOpenCodeCacheDir()
|
||||
const packageJsonPath = `${cacheDir}/package.json`
|
||||
|
||||
@@ -31,17 +78,23 @@ export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
|
||||
try {
|
||||
const proc = spawnWithWindowsHide(["bun", "install"], {
|
||||
cwd: cacheDir,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
stdout: outputMode,
|
||||
stderr: outputMode,
|
||||
})
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout>
|
||||
const outputPromise = Promise.all([readProcessOutput(proc.stdout), readProcessOutput(proc.stderr)]).then(
|
||||
([stdout, stderr]) => ({ stdout, stderr })
|
||||
)
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
const timeoutPromise = new Promise<"timeout">((resolve) => {
|
||||
timeoutId = setTimeout(() => resolve("timeout"), BUN_INSTALL_TIMEOUT_MS)
|
||||
})
|
||||
const exitPromise = proc.exited.then(() => "completed" as const)
|
||||
const result = await Promise.race([exitPromise, timeoutPromise])
|
||||
clearTimeout(timeoutId!)
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
|
||||
if (result === "timeout") {
|
||||
try {
|
||||
@@ -49,6 +102,17 @@ export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
|
||||
} catch (err) {
|
||||
log("[cli/install] Failed to kill timed out bun install process:", err)
|
||||
}
|
||||
|
||||
if (outputMode === "pipe") {
|
||||
void outputPromise
|
||||
.then((output) => {
|
||||
logCapturedOutputOnFailure(outputMode, output)
|
||||
})
|
||||
.catch((err) => {
|
||||
log("[bun-install] Failed to read captured output after timeout:", err)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
timedOut: true,
|
||||
@@ -56,7 +120,11 @@ export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
|
||||
}
|
||||
}
|
||||
|
||||
const output = await outputPromise
|
||||
|
||||
if (proc.exitCode !== 0) {
|
||||
logCapturedOutputOnFailure(outputMode, output)
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `bun install failed with exit code ${proc.exitCode}`,
|
||||
|
||||
@@ -1,8 +1,111 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { afterEach, describe, expect, it } from "bun:test"
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { dirname, join } from "node:path"
|
||||
|
||||
import { getSuggestedInstallTag } from "./system-loaded-version"
|
||||
import { PACKAGE_NAME } from "../constants"
|
||||
|
||||
const systemLoadedVersionModulePath = "./system-loaded-version?system-loaded-version-test"
|
||||
|
||||
const { getLoadedPluginVersion, getSuggestedInstallTag }: typeof import("./system-loaded-version") =
|
||||
await import(systemLoadedVersionModulePath)
|
||||
|
||||
const originalOpencodeConfigDir = process.env.OPENCODE_CONFIG_DIR
|
||||
const originalXdgCacheHome = process.env.XDG_CACHE_HOME
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
function createTemporaryDirectory(prefix: string): string {
|
||||
const directory = mkdtempSync(join(tmpdir(), prefix))
|
||||
temporaryDirectories.push(directory)
|
||||
return directory
|
||||
}
|
||||
|
||||
function writeJson(filePath: string, value: Record<string, string | Record<string, string>>): void {
|
||||
mkdirSync(dirname(filePath), { recursive: true })
|
||||
writeFileSync(filePath, JSON.stringify(value), "utf-8")
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (originalOpencodeConfigDir === undefined) {
|
||||
delete process.env.OPENCODE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.OPENCODE_CONFIG_DIR = originalOpencodeConfigDir
|
||||
}
|
||||
|
||||
if (originalXdgCacheHome === undefined) {
|
||||
delete process.env.XDG_CACHE_HOME
|
||||
} else {
|
||||
process.env.XDG_CACHE_HOME = originalXdgCacheHome
|
||||
}
|
||||
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe("system loaded version", () => {
|
||||
describe("getLoadedPluginVersion", () => {
|
||||
it("prefers the config directory when both installs exist", () => {
|
||||
//#given
|
||||
const configDir = createTemporaryDirectory("omo-config-")
|
||||
const cacheHome = createTemporaryDirectory("omo-cache-")
|
||||
const cacheDir = join(cacheHome, "opencode")
|
||||
|
||||
process.env.OPENCODE_CONFIG_DIR = configDir
|
||||
process.env.XDG_CACHE_HOME = cacheHome
|
||||
|
||||
writeJson(join(configDir, "package.json"), {
|
||||
dependencies: { [PACKAGE_NAME]: "1.2.3" },
|
||||
})
|
||||
writeJson(join(configDir, "node_modules", PACKAGE_NAME, "package.json"), {
|
||||
version: "1.2.3",
|
||||
})
|
||||
writeJson(join(cacheDir, "package.json"), {
|
||||
dependencies: { [PACKAGE_NAME]: "9.9.9" },
|
||||
})
|
||||
writeJson(join(cacheDir, "node_modules", PACKAGE_NAME, "package.json"), {
|
||||
version: "9.9.9",
|
||||
})
|
||||
|
||||
//#when
|
||||
const loadedVersion = getLoadedPluginVersion()
|
||||
|
||||
//#then
|
||||
expect(loadedVersion.cacheDir).toBe(configDir)
|
||||
expect(loadedVersion.cachePackagePath).toBe(join(configDir, "package.json"))
|
||||
expect(loadedVersion.installedPackagePath).toBe(join(configDir, "node_modules", PACKAGE_NAME, "package.json"))
|
||||
expect(loadedVersion.expectedVersion).toBe("1.2.3")
|
||||
expect(loadedVersion.loadedVersion).toBe("1.2.3")
|
||||
})
|
||||
|
||||
it("falls back to the cache directory for legacy installs", () => {
|
||||
//#given
|
||||
const configDir = createTemporaryDirectory("omo-config-")
|
||||
const cacheHome = createTemporaryDirectory("omo-cache-")
|
||||
const cacheDir = join(cacheHome, "opencode")
|
||||
|
||||
process.env.OPENCODE_CONFIG_DIR = configDir
|
||||
process.env.XDG_CACHE_HOME = cacheHome
|
||||
|
||||
writeJson(join(cacheDir, "package.json"), {
|
||||
dependencies: { [PACKAGE_NAME]: "2.3.4" },
|
||||
})
|
||||
writeJson(join(cacheDir, "node_modules", PACKAGE_NAME, "package.json"), {
|
||||
version: "2.3.4",
|
||||
})
|
||||
|
||||
//#when
|
||||
const loadedVersion = getLoadedPluginVersion()
|
||||
|
||||
//#then
|
||||
expect(loadedVersion.cacheDir).toBe(cacheDir)
|
||||
expect(loadedVersion.cachePackagePath).toBe(join(cacheDir, "package.json"))
|
||||
expect(loadedVersion.installedPackagePath).toBe(join(cacheDir, "node_modules", PACKAGE_NAME, "package.json"))
|
||||
expect(loadedVersion.expectedVersion).toBe("2.3.4")
|
||||
expect(loadedVersion.loadedVersion).toBe("2.3.4")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getSuggestedInstallTag", () => {
|
||||
it("returns prerelease channel when current version is prerelease", () => {
|
||||
//#given
|
||||
|
||||
@@ -5,7 +5,7 @@ import { join } from "node:path"
|
||||
import { getLatestVersion } from "../../../hooks/auto-update-checker/checker"
|
||||
import { extractChannel } from "../../../hooks/auto-update-checker"
|
||||
import { PACKAGE_NAME } from "../constants"
|
||||
import { getOpenCodeCacheDir, parseJsonc } from "../../../shared"
|
||||
import { getOpenCodeCacheDir, getOpenCodeConfigPaths, parseJsonc } from "../../../shared"
|
||||
|
||||
interface PackageJsonShape {
|
||||
version?: string
|
||||
@@ -54,9 +54,24 @@ function normalizeVersion(value: string | undefined): string | null {
|
||||
}
|
||||
|
||||
export function getLoadedPluginVersion(): LoadedVersionInfo {
|
||||
const configPaths = getOpenCodeConfigPaths({ binary: "opencode" })
|
||||
const cacheDir = resolveOpenCodeCacheDir()
|
||||
const cachePackagePath = join(cacheDir, "package.json")
|
||||
const installedPackagePath = join(cacheDir, "node_modules", PACKAGE_NAME, "package.json")
|
||||
const candidates = [
|
||||
{
|
||||
cacheDir: configPaths.configDir,
|
||||
cachePackagePath: configPaths.packageJson,
|
||||
installedPackagePath: join(configPaths.configDir, "node_modules", PACKAGE_NAME, "package.json"),
|
||||
},
|
||||
{
|
||||
cacheDir,
|
||||
cachePackagePath: join(cacheDir, "package.json"),
|
||||
installedPackagePath: join(cacheDir, "node_modules", PACKAGE_NAME, "package.json"),
|
||||
},
|
||||
]
|
||||
|
||||
const selectedCandidate = candidates.find((candidate) => existsSync(candidate.installedPackagePath)) ?? candidates[0]
|
||||
|
||||
const { cacheDir: selectedDir, cachePackagePath, installedPackagePath } = selectedCandidate
|
||||
|
||||
const cachePackage = readPackageJson(cachePackagePath)
|
||||
const installedPackage = readPackageJson(installedPackagePath)
|
||||
@@ -65,7 +80,7 @@ export function getLoadedPluginVersion(): LoadedVersionInfo {
|
||||
const loadedVersion = normalizeVersion(installedPackage?.version)
|
||||
|
||||
return {
|
||||
cacheDir,
|
||||
cacheDir: selectedDir,
|
||||
cachePackagePath,
|
||||
installedPackagePath,
|
||||
expectedVersion,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { run } from "./runner"
|
||||
export { resolveRunAgent } from "./agent-resolver"
|
||||
export { resolveRunModel } from "./model-resolver"
|
||||
export { createServerConnection } from "./server-connection"
|
||||
export { resolveSession } from "./session-resolver"
|
||||
export { createJsonOutputManager } from "./json-output"
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { resolveRunModel } from "./model-resolver"
|
||||
|
||||
describe("resolveRunModel", () => {
|
||||
it("given no model string, when resolved, then returns undefined", () => {
|
||||
// given
|
||||
const modelString = undefined
|
||||
|
||||
// when
|
||||
const result = resolveRunModel(modelString)
|
||||
|
||||
// then
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it("given empty string, when resolved, then throws Error", () => {
|
||||
// given
|
||||
const modelString = ""
|
||||
|
||||
// when
|
||||
const resolve = () => resolveRunModel(modelString)
|
||||
|
||||
// then
|
||||
expect(resolve).toThrow()
|
||||
})
|
||||
|
||||
it("given valid 'anthropic/claude-sonnet-4', when resolved, then returns correct object", () => {
|
||||
// given
|
||||
const modelString = "anthropic/claude-sonnet-4"
|
||||
|
||||
// when
|
||||
const result = resolveRunModel(modelString)
|
||||
|
||||
// then
|
||||
expect(result).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4" })
|
||||
})
|
||||
|
||||
it("given nested slashes 'openai/gpt-5.3/preview', when resolved, then modelID is 'gpt-5.3/preview'", () => {
|
||||
// given
|
||||
const modelString = "openai/gpt-5.3/preview"
|
||||
|
||||
// when
|
||||
const result = resolveRunModel(modelString)
|
||||
|
||||
// then
|
||||
expect(result).toEqual({ providerID: "openai", modelID: "gpt-5.3/preview" })
|
||||
})
|
||||
|
||||
it("given no slash 'claude-sonnet-4', when resolved, then throws Error", () => {
|
||||
// given
|
||||
const modelString = "claude-sonnet-4"
|
||||
|
||||
// when
|
||||
const resolve = () => resolveRunModel(modelString)
|
||||
|
||||
// then
|
||||
expect(resolve).toThrow()
|
||||
})
|
||||
|
||||
it("given empty provider '/claude-sonnet-4', when resolved, then throws Error", () => {
|
||||
// given
|
||||
const modelString = "/claude-sonnet-4"
|
||||
|
||||
// when
|
||||
const resolve = () => resolveRunModel(modelString)
|
||||
|
||||
// then
|
||||
expect(resolve).toThrow()
|
||||
})
|
||||
|
||||
it("given trailing slash 'anthropic/', when resolved, then throws Error", () => {
|
||||
// given
|
||||
const modelString = "anthropic/"
|
||||
|
||||
// when
|
||||
const resolve = () => resolveRunModel(modelString)
|
||||
|
||||
// then
|
||||
expect(resolve).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
export function resolveRunModel(
|
||||
modelString?: string
|
||||
): { providerID: string; modelID: string } | undefined {
|
||||
if (modelString === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const trimmed = modelString.trim()
|
||||
if (trimmed.length === 0) {
|
||||
throw new Error("Model string cannot be empty")
|
||||
}
|
||||
|
||||
const parts = trimmed.split("/")
|
||||
if (parts.length < 2) {
|
||||
throw new Error("Model string must be in 'provider/model' format")
|
||||
}
|
||||
|
||||
const providerID = parts[0]
|
||||
if (providerID.length === 0) {
|
||||
throw new Error("Provider cannot be empty")
|
||||
}
|
||||
|
||||
const modelID = parts.slice(1).join("/")
|
||||
if (modelID.length === 0) {
|
||||
throw new Error("Model ID cannot be empty")
|
||||
}
|
||||
|
||||
return { providerID, modelID }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, mock, spyOn } from "bun:test"
|
||||
import { afterEach, beforeEach, describe, it, expect, mock, spyOn } from "bun:test"
|
||||
import type { RunContext, Todo, ChildSession, SessionStatus } from "./types"
|
||||
import { createEventState } from "./events"
|
||||
import { pollForCompletion } from "./poll-for-completion"
|
||||
@@ -30,11 +30,26 @@ const createMockContext = (overrides: {
|
||||
}
|
||||
}
|
||||
|
||||
let consoleLogSpy: ReturnType<typeof spyOn>
|
||||
let consoleErrorSpy: ReturnType<typeof spyOn>
|
||||
|
||||
function abortAfter(abortController: AbortController, delayMs: number): void {
|
||||
setTimeout(() => abortController.abort(), delayMs)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
consoleLogSpy = spyOn(console, "log").mockImplementation(() => {})
|
||||
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
consoleLogSpy.mockRestore()
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
describe("pollForCompletion", () => {
|
||||
it("requires consecutive stability checks before exiting - not immediate", async () => {
|
||||
//#given - 0 todos, 0 children, session idle, meaningful work done
|
||||
spyOn(console, "log").mockImplementation(() => {})
|
||||
spyOn(console, "error").mockImplementation(() => {})
|
||||
const ctx = createMockContext()
|
||||
const eventState = createEventState()
|
||||
eventState.mainSessionIdle = true
|
||||
@@ -56,8 +71,6 @@ describe("pollForCompletion", () => {
|
||||
|
||||
it("does not check completion during stabilization period after first meaningful work", async () => {
|
||||
//#given - session idle, meaningful work done, but stabilization period not elapsed
|
||||
spyOn(console, "log").mockImplementation(() => {})
|
||||
spyOn(console, "error").mockImplementation(() => {})
|
||||
const ctx = createMockContext()
|
||||
const eventState = createEventState()
|
||||
eventState.mainSessionIdle = true
|
||||
@@ -65,7 +78,7 @@ describe("pollForCompletion", () => {
|
||||
const abortController = new AbortController()
|
||||
|
||||
//#when - abort after 50ms (within the 60ms stabilization period)
|
||||
setTimeout(() => abortController.abort(), 50)
|
||||
abortAfter(abortController, 50)
|
||||
const result = await pollForCompletion(ctx, eventState, abortController, {
|
||||
pollIntervalMs: 10,
|
||||
requiredConsecutive: 3,
|
||||
@@ -80,8 +93,6 @@ describe("pollForCompletion", () => {
|
||||
|
||||
it("does not exit when currentTool is set - resets consecutive counter", async () => {
|
||||
//#given
|
||||
spyOn(console, "log").mockImplementation(() => {})
|
||||
spyOn(console, "error").mockImplementation(() => {})
|
||||
const ctx = createMockContext()
|
||||
const eventState = createEventState()
|
||||
eventState.mainSessionIdle = true
|
||||
@@ -90,7 +101,7 @@ describe("pollForCompletion", () => {
|
||||
const abortController = new AbortController()
|
||||
|
||||
//#when - abort after enough time to verify it didn't exit
|
||||
setTimeout(() => abortController.abort(), 100)
|
||||
abortAfter(abortController, 100)
|
||||
const result = await pollForCompletion(ctx, eventState, abortController, {
|
||||
pollIntervalMs: 10,
|
||||
requiredConsecutive: 3,
|
||||
@@ -105,8 +116,6 @@ describe("pollForCompletion", () => {
|
||||
|
||||
it("resets consecutive counter when session becomes busy between checks", async () => {
|
||||
//#given
|
||||
spyOn(console, "log").mockImplementation(() => {})
|
||||
spyOn(console, "error").mockImplementation(() => {})
|
||||
const ctx = createMockContext()
|
||||
const eventState = createEventState()
|
||||
eventState.mainSessionIdle = true
|
||||
@@ -147,8 +156,6 @@ describe("pollForCompletion", () => {
|
||||
|
||||
it("returns 1 on session error", async () => {
|
||||
//#given
|
||||
spyOn(console, "log").mockImplementation(() => {})
|
||||
spyOn(console, "error").mockImplementation(() => {})
|
||||
const ctx = createMockContext()
|
||||
const eventState = createEventState()
|
||||
eventState.mainSessionIdle = true
|
||||
@@ -169,14 +176,12 @@ describe("pollForCompletion", () => {
|
||||
|
||||
it("returns 130 when aborted", async () => {
|
||||
//#given
|
||||
spyOn(console, "log").mockImplementation(() => {})
|
||||
spyOn(console, "error").mockImplementation(() => {})
|
||||
const ctx = createMockContext()
|
||||
const eventState = createEventState()
|
||||
const abortController = new AbortController()
|
||||
|
||||
//#when
|
||||
setTimeout(() => abortController.abort(), 50)
|
||||
abortAfter(abortController, 50)
|
||||
const result = await pollForCompletion(ctx, eventState, abortController, {
|
||||
pollIntervalMs: 10,
|
||||
requiredConsecutive: 3,
|
||||
@@ -188,8 +193,6 @@ describe("pollForCompletion", () => {
|
||||
|
||||
it("does not check completion when hasReceivedMeaningfulWork is false", async () => {
|
||||
//#given
|
||||
spyOn(console, "log").mockImplementation(() => {})
|
||||
spyOn(console, "error").mockImplementation(() => {})
|
||||
const ctx = createMockContext()
|
||||
const eventState = createEventState()
|
||||
eventState.mainSessionIdle = true
|
||||
@@ -197,7 +200,7 @@ describe("pollForCompletion", () => {
|
||||
const abortController = new AbortController()
|
||||
|
||||
//#when
|
||||
setTimeout(() => abortController.abort(), 100)
|
||||
abortAfter(abortController, 100)
|
||||
const result = await pollForCompletion(ctx, eventState, abortController, {
|
||||
pollIntervalMs: 10,
|
||||
requiredConsecutive: 3,
|
||||
@@ -211,8 +214,6 @@ describe("pollForCompletion", () => {
|
||||
|
||||
it("falls back to session.status API when idle event is missing", async () => {
|
||||
//#given - mainSessionIdle not set by events, but status API says idle
|
||||
spyOn(console, "log").mockImplementation(() => {})
|
||||
spyOn(console, "error").mockImplementation(() => {})
|
||||
const ctx = createMockContext({
|
||||
statuses: {
|
||||
"test-session": { type: "idle" },
|
||||
@@ -236,8 +237,6 @@ describe("pollForCompletion", () => {
|
||||
|
||||
it("allows silent completion after stabilization when no meaningful work is received", async () => {
|
||||
//#given - session is idle and stable but no assistant message/tool event arrived
|
||||
spyOn(console, "log").mockImplementation(() => {})
|
||||
spyOn(console, "error").mockImplementation(() => {})
|
||||
const ctx = createMockContext()
|
||||
const eventState = createEventState()
|
||||
eventState.mainSessionIdle = true
|
||||
@@ -257,8 +256,6 @@ describe("pollForCompletion", () => {
|
||||
|
||||
it("uses default stabilization to avoid indefinite wait when no meaningful work arrives", async () => {
|
||||
//#given - idle with no meaningful work and no explicit minStabilization override
|
||||
spyOn(console, "log").mockImplementation(() => {})
|
||||
spyOn(console, "error").mockImplementation(() => {})
|
||||
const ctx = createMockContext()
|
||||
const eventState = createEventState()
|
||||
eventState.mainSessionIdle = true
|
||||
@@ -277,8 +274,6 @@ describe("pollForCompletion", () => {
|
||||
|
||||
it("coerces non-positive stabilization values to default stabilization", async () => {
|
||||
//#given - explicit zero stabilization should still wait for default window
|
||||
spyOn(console, "log").mockImplementation(() => {})
|
||||
spyOn(console, "error").mockImplementation(() => {})
|
||||
const ctx = createMockContext()
|
||||
const eventState = createEventState()
|
||||
eventState.mainSessionIdle = true
|
||||
@@ -286,7 +281,7 @@ describe("pollForCompletion", () => {
|
||||
const abortController = new AbortController()
|
||||
|
||||
//#when - abort before default 1s window elapses
|
||||
setTimeout(() => abortController.abort(), 100)
|
||||
abortAfter(abortController, 100)
|
||||
const result = await pollForCompletion(ctx, eventState, abortController, {
|
||||
pollIntervalMs: 10,
|
||||
requiredConsecutive: 1,
|
||||
@@ -299,8 +294,6 @@ describe("pollForCompletion", () => {
|
||||
|
||||
it("simulates race condition: brief idle with 0 todos does not cause immediate exit", async () => {
|
||||
//#given - simulate Sisyphus outputting text, session goes idle briefly, then tool fires
|
||||
spyOn(console, "log").mockImplementation(() => {})
|
||||
spyOn(console, "error").mockImplementation(() => {})
|
||||
const ctx = createMockContext()
|
||||
const eventState = createEventState()
|
||||
eventState.mainSessionIdle = true
|
||||
@@ -323,7 +316,7 @@ describe("pollForCompletion", () => {
|
||||
)
|
||||
|
||||
//#when - abort after tool stays in-flight
|
||||
setTimeout(() => abortController.abort(), 200)
|
||||
abortAfter(abortController, 200)
|
||||
const result = await pollForCompletion(ctx, eventState, abortController, {
|
||||
pollIntervalMs: 10,
|
||||
requiredConsecutive: 3,
|
||||
@@ -335,8 +328,6 @@ describe("pollForCompletion", () => {
|
||||
|
||||
it("returns 1 when session errors while not idle (error not masked by idle gate)", async () => {
|
||||
//#given - mainSessionIdle=false, mainSessionError=true, lastError="crash"
|
||||
spyOn(console, "log").mockImplementation(() => {})
|
||||
spyOn(console, "error").mockImplementation(() => {})
|
||||
const ctx = createMockContext()
|
||||
const eventState = createEventState()
|
||||
eventState.mainSessionIdle = false
|
||||
@@ -359,8 +350,6 @@ describe("pollForCompletion", () => {
|
||||
|
||||
it("returns 1 when session errors while tool is active (error not masked by tool gate)", async () => {
|
||||
//#given - mainSessionIdle=true, currentTool="bash", mainSessionError=true
|
||||
spyOn(console, "log").mockImplementation(() => {})
|
||||
spyOn(console, "error").mockImplementation(() => {})
|
||||
const ctx = createMockContext()
|
||||
const eventState = createEventState()
|
||||
eventState.mainSessionIdle = true
|
||||
|
||||
@@ -7,6 +7,7 @@ import { resolveSession } from "./session-resolver"
|
||||
import { createJsonOutputManager } from "./json-output"
|
||||
import { executeOnCompleteHook } from "./on-complete-hook"
|
||||
import { resolveRunAgent } from "./agent-resolver"
|
||||
import { resolveRunModel } from "./model-resolver"
|
||||
import { pollForCompletion } from "./poll-for-completion"
|
||||
import { loadAgentProfileColors } from "./agent-profile-colors"
|
||||
import { suppressRunInput } from "./stdin-suppression"
|
||||
@@ -46,6 +47,7 @@ export async function run(options: RunOptions): Promise<number> {
|
||||
|
||||
const pluginConfig = loadPluginConfig(directory, { command: "run" })
|
||||
const resolvedAgent = resolveRunAgent(options, pluginConfig)
|
||||
const resolvedModel = resolveRunModel(options.model)
|
||||
const abortController = new AbortController()
|
||||
|
||||
try {
|
||||
@@ -78,6 +80,10 @@ export async function run(options: RunOptions): Promise<number> {
|
||||
|
||||
console.log(pc.dim(`Session: ${sessionID}`))
|
||||
|
||||
if (resolvedModel) {
|
||||
console.log(pc.dim(`Model: ${resolvedModel.providerID}/${resolvedModel.modelID}`))
|
||||
}
|
||||
|
||||
const ctx: RunContext = {
|
||||
client,
|
||||
sessionID,
|
||||
@@ -96,6 +102,7 @@ export async function run(options: RunOptions): Promise<number> {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: resolvedAgent,
|
||||
...(resolvedModel ? { model: resolvedModel } : {}),
|
||||
tools: {
|
||||
question: false,
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ export type { OpencodeClient }
|
||||
export interface RunOptions {
|
||||
message: string
|
||||
agent?: string
|
||||
model?: string
|
||||
timestamp?: boolean
|
||||
verbose?: boolean
|
||||
directory?: string
|
||||
|
||||
@@ -884,6 +884,25 @@ describe("GitMasterConfigSchema", () => {
|
||||
//#then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("accepts shell-safe git_env_prefix", () => {
|
||||
const config = { git_env_prefix: "MY_HOOK=active" }
|
||||
|
||||
const result = GitMasterConfigSchema.safeParse(config)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.git_env_prefix).toBe("MY_HOOK=active")
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects git_env_prefix with shell metacharacters", () => {
|
||||
const config = { git_env_prefix: "A=1; rm -rf /" }
|
||||
|
||||
const result = GitMasterConfigSchema.safeParse(config)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("skills schema", () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ export * from "./schema/commands"
|
||||
export * from "./schema/dynamic-context-pruning"
|
||||
export * from "./schema/experimental"
|
||||
export * from "./schema/fallback-models"
|
||||
export * from "./schema/git-env-prefix"
|
||||
export * from "./schema/git-master"
|
||||
export * from "./schema/hooks"
|
||||
export * from "./schema/notification"
|
||||
|
||||
@@ -3,6 +3,54 @@ import { ZodError } from "zod/v4"
|
||||
import { BackgroundTaskConfigSchema } from "./background-task"
|
||||
|
||||
describe("BackgroundTaskConfigSchema", () => {
|
||||
describe("maxDepth", () => {
|
||||
describe("#given valid maxDepth (3)", () => {
|
||||
test("#when parsed #then returns correct value", () => {
|
||||
const result = BackgroundTaskConfigSchema.parse({ maxDepth: 3 })
|
||||
|
||||
expect(result.maxDepth).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given maxDepth below minimum (0)", () => {
|
||||
test("#when parsed #then throws ZodError", () => {
|
||||
let thrownError: unknown
|
||||
|
||||
try {
|
||||
BackgroundTaskConfigSchema.parse({ maxDepth: 0 })
|
||||
} catch (error) {
|
||||
thrownError = error
|
||||
}
|
||||
|
||||
expect(thrownError).toBeInstanceOf(ZodError)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("maxDescendants", () => {
|
||||
describe("#given valid maxDescendants (50)", () => {
|
||||
test("#when parsed #then returns correct value", () => {
|
||||
const result = BackgroundTaskConfigSchema.parse({ maxDescendants: 50 })
|
||||
|
||||
expect(result.maxDescendants).toBe(50)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given maxDescendants below minimum (0)", () => {
|
||||
test("#when parsed #then throws ZodError", () => {
|
||||
let thrownError: unknown
|
||||
|
||||
try {
|
||||
BackgroundTaskConfigSchema.parse({ maxDescendants: 0 })
|
||||
} catch (error) {
|
||||
thrownError = error
|
||||
}
|
||||
|
||||
expect(thrownError).toBeInstanceOf(ZodError)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("syncPollTimeoutMs", () => {
|
||||
describe("#given valid syncPollTimeoutMs (120000)", () => {
|
||||
test("#when parsed #then returns correct value", () => {
|
||||
|
||||
@@ -4,6 +4,8 @@ export const BackgroundTaskConfigSchema = z.object({
|
||||
defaultConcurrency: z.number().min(1).optional(),
|
||||
providerConcurrency: z.record(z.string(), z.number().min(0)).optional(),
|
||||
modelConcurrency: z.record(z.string(), z.number().min(0)).optional(),
|
||||
maxDepth: z.number().int().min(1).optional(),
|
||||
maxDescendants: z.number().int().min(1).optional(),
|
||||
/** Stale timeout in milliseconds - interrupt tasks with no activity for this duration (default: 180000 = 3 minutes, minimum: 60000 = 1 minute) */
|
||||
staleTimeoutMs: z.number().min(60000).optional(),
|
||||
/** Timeout for tasks that never received any progress update, falling back to startedAt (default: 600000 = 10 minutes, minimum: 60000 = 1 minute) */
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from "zod"
|
||||
|
||||
const GIT_ENV_ASSIGNMENT_PATTERN =
|
||||
/^(?:[A-Za-z_][A-Za-z0-9_]*=[A-Za-z0-9_-]*)(?: [A-Za-z_][A-Za-z0-9_]*=[A-Za-z0-9_-]*)*$/
|
||||
|
||||
export const GIT_ENV_PREFIX_VALIDATION_MESSAGE =
|
||||
'git_env_prefix must be empty or use shell-safe env assignments like "GIT_MASTER=1"'
|
||||
|
||||
export function isValidGitEnvPrefix(value: string): boolean {
|
||||
if (value === "") {
|
||||
return true
|
||||
}
|
||||
|
||||
return GIT_ENV_ASSIGNMENT_PATTERN.test(value)
|
||||
}
|
||||
|
||||
export function assertValidGitEnvPrefix(value: string): string {
|
||||
if (!isValidGitEnvPrefix(value)) {
|
||||
throw new Error(GIT_ENV_PREFIX_VALIDATION_MESSAGE)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
export const GitEnvPrefixSchema = z
|
||||
.string()
|
||||
.refine(isValidGitEnvPrefix, { message: GIT_ENV_PREFIX_VALIDATION_MESSAGE })
|
||||
.default("GIT_MASTER=1")
|
||||
@@ -1,10 +1,14 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { GitEnvPrefixSchema } from "./git-env-prefix"
|
||||
|
||||
export const GitMasterConfigSchema = z.object({
|
||||
/** Add "Ultraworked with Sisyphus" footer to commit messages (default: true). Can be boolean or custom string. */
|
||||
commit_footer: z.union([z.boolean(), z.string()]).default(true),
|
||||
/** Add "Co-authored-by: Sisyphus" trailer to commit messages (default: true) */
|
||||
include_co_authored_by: z.boolean().default(true),
|
||||
/** Environment variable prefix for all git commands (default: "GIT_MASTER=1"). Set to "" to disable. Allows custom git hooks to detect git-master skill usage. */
|
||||
git_env_prefix: GitEnvPrefixSchema,
|
||||
})
|
||||
|
||||
export type GitMasterConfig = z.infer<typeof GitMasterConfigSchema>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const HookNameSchema = z.enum([
|
||||
"gpt-permission-continuation",
|
||||
"todo-continuation-enforcer",
|
||||
"context-window-monitor",
|
||||
"session-recovery",
|
||||
|
||||
@@ -11,6 +11,7 @@ import { CommentCheckerConfigSchema } from "./comment-checker"
|
||||
import { BuiltinCommandNameSchema } from "./commands"
|
||||
import { ExperimentalConfigSchema } from "./experimental"
|
||||
import { GitMasterConfigSchema } from "./git-master"
|
||||
import { HookNameSchema } from "./hooks"
|
||||
import { NotificationConfigSchema } from "./notification"
|
||||
import { RalphLoopConfigSchema } from "./ralph-loop"
|
||||
import { RuntimeFallbackConfigSchema } from "./runtime-fallback"
|
||||
@@ -30,7 +31,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
|
||||
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
|
||||
disabled_agents: z.array(z.string()).optional(),
|
||||
disabled_skills: z.array(BuiltinSkillNameSchema).optional(),
|
||||
disabled_hooks: z.array(z.string()).optional(),
|
||||
disabled_hooks: z.array(HookNameSchema).optional(),
|
||||
disabled_commands: z.array(BuiltinCommandNameSchema).optional(),
|
||||
/** Disable specific tools by name (e.g., ["todowrite", "todoread"]) */
|
||||
disabled_tools: z.array(z.string()).optional(),
|
||||
|
||||
@@ -2,7 +2,15 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { mkdtempSync, writeFileSync, rmSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
import { isCompactionAgent, findNearestMessageExcludingCompaction } from "./compaction-aware-message-resolver"
|
||||
import {
|
||||
isCompactionAgent,
|
||||
findNearestMessageExcludingCompaction,
|
||||
resolvePromptContextFromSessionMessages,
|
||||
} from "./compaction-aware-message-resolver"
|
||||
import {
|
||||
clearCompactionAgentConfigCheckpoint,
|
||||
setCompactionAgentConfigCheckpoint,
|
||||
} from "../../shared/compaction-agent-config-checkpoint"
|
||||
|
||||
describe("isCompactionAgent", () => {
|
||||
describe("#given agent name variations", () => {
|
||||
@@ -65,6 +73,7 @@ describe("findNearestMessageExcludingCompaction", () => {
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { force: true, recursive: true })
|
||||
clearCompactionAgentConfigCheckpoint("ses_checkpoint")
|
||||
})
|
||||
|
||||
describe("#given directory with messages", () => {
|
||||
@@ -186,5 +195,65 @@ describe("findNearestMessageExcludingCompaction", () => {
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.agent).toBe("newer")
|
||||
})
|
||||
|
||||
test("merges partial metadata from multiple recent messages", () => {
|
||||
// given
|
||||
writeFileSync(
|
||||
join(tempDir, "003.json"),
|
||||
JSON.stringify({ model: { providerID: "anthropic", modelID: "claude-opus-4-1" } }),
|
||||
)
|
||||
writeFileSync(join(tempDir, "002.json"), JSON.stringify({ agent: "atlas" }))
|
||||
writeFileSync(join(tempDir, "001.json"), JSON.stringify({ tools: { bash: true } }))
|
||||
|
||||
// when
|
||||
const result = findNearestMessageExcludingCompaction(tempDir)
|
||||
|
||||
// then
|
||||
expect(result).toEqual({
|
||||
agent: "atlas",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-1" },
|
||||
tools: { bash: true },
|
||||
})
|
||||
})
|
||||
|
||||
test("fills missing metadata from compaction checkpoint", () => {
|
||||
// given
|
||||
setCompactionAgentConfigCheckpoint("ses_checkpoint", {
|
||||
agent: "sisyphus",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
})
|
||||
writeFileSync(join(tempDir, "001.json"), JSON.stringify({ tools: { bash: true } }))
|
||||
|
||||
// when
|
||||
const result = findNearestMessageExcludingCompaction(tempDir, "ses_checkpoint")
|
||||
|
||||
// then
|
||||
expect(result).toEqual({
|
||||
agent: "sisyphus",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
tools: { bash: true },
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolvePromptContextFromSessionMessages", () => {
|
||||
test("merges partial prompt context from recent SDK messages", () => {
|
||||
// given
|
||||
const messages = [
|
||||
{ info: { agent: "atlas" } },
|
||||
{ info: { model: { providerID: "anthropic", modelID: "claude-opus-4-1" } } },
|
||||
{ info: { tools: { bash: true } } },
|
||||
]
|
||||
|
||||
// when
|
||||
const result = resolvePromptContextFromSessionMessages(messages)
|
||||
|
||||
// then
|
||||
expect(result).toEqual({
|
||||
agent: "atlas",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-1" },
|
||||
tools: { bash: true },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import { readdirSync, readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import type { StoredMessage } from "../hook-message-injector"
|
||||
import { getCompactionAgentConfigCheckpoint } from "../../shared/compaction-agent-config-checkpoint"
|
||||
|
||||
type SessionMessage = {
|
||||
info?: {
|
||||
agent?: string
|
||||
model?: {
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
variant?: string
|
||||
}
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
tools?: StoredMessage["tools"]
|
||||
}
|
||||
}
|
||||
|
||||
export function isCompactionAgent(agent: string | undefined): boolean {
|
||||
return agent?.trim().toLowerCase() === "compaction"
|
||||
@@ -16,42 +31,121 @@ function hasFullAgentAndModel(message: StoredMessage): boolean {
|
||||
function hasPartialAgentOrModel(message: StoredMessage): boolean {
|
||||
const hasAgent = !!message.agent && !isCompactionAgent(message.agent)
|
||||
const hasModel = !!message.model?.providerID && !!message.model?.modelID
|
||||
return hasAgent || hasModel
|
||||
return hasAgent || hasModel || !!message.tools
|
||||
}
|
||||
|
||||
export function findNearestMessageExcludingCompaction(messageDir: string): StoredMessage | null {
|
||||
try {
|
||||
const files = readdirSync(messageDir)
|
||||
.filter((name) => name.endsWith(".json"))
|
||||
.sort()
|
||||
.reverse()
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = readFileSync(join(messageDir, file), "utf-8")
|
||||
const parsed = JSON.parse(content) as StoredMessage
|
||||
if (hasFullAgentAndModel(parsed)) {
|
||||
return parsed
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = readFileSync(join(messageDir, file), "utf-8")
|
||||
const parsed = JSON.parse(content) as StoredMessage
|
||||
if (hasPartialAgentOrModel(parsed)) {
|
||||
return parsed
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
function convertSessionMessageToStoredMessage(message: SessionMessage): StoredMessage | null {
|
||||
const info = message.info
|
||||
if (!info) {
|
||||
return null
|
||||
}
|
||||
|
||||
return null
|
||||
const providerID = info.model?.providerID ?? info.providerID
|
||||
const modelID = info.model?.modelID ?? info.modelID
|
||||
|
||||
return {
|
||||
...(info.agent ? { agent: info.agent } : {}),
|
||||
...(providerID && modelID
|
||||
? {
|
||||
model: {
|
||||
providerID,
|
||||
modelID,
|
||||
...(info.model?.variant ? { variant: info.model.variant } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(info.tools ? { tools: info.tools } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function mergeStoredMessages(
|
||||
messages: Array<StoredMessage | null>,
|
||||
sessionID?: string,
|
||||
): StoredMessage | null {
|
||||
const merged: StoredMessage = {}
|
||||
|
||||
for (const message of messages) {
|
||||
if (!message || isCompactionAgent(message.agent)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!merged.agent && message.agent) {
|
||||
merged.agent = message.agent
|
||||
}
|
||||
|
||||
if (!merged.model?.providerID && message.model?.providerID && message.model.modelID) {
|
||||
merged.model = {
|
||||
providerID: message.model.providerID,
|
||||
modelID: message.model.modelID,
|
||||
...(message.model.variant ? { variant: message.model.variant } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
if (!merged.tools && message.tools) {
|
||||
merged.tools = message.tools
|
||||
}
|
||||
|
||||
if (hasFullAgentAndModel(merged) && merged.tools) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const checkpoint = sessionID
|
||||
? getCompactionAgentConfigCheckpoint(sessionID)
|
||||
: undefined
|
||||
|
||||
if (!merged.agent && checkpoint?.agent) {
|
||||
merged.agent = checkpoint.agent
|
||||
}
|
||||
|
||||
if (!merged.model && checkpoint?.model) {
|
||||
merged.model = {
|
||||
providerID: checkpoint.model.providerID,
|
||||
modelID: checkpoint.model.modelID,
|
||||
}
|
||||
}
|
||||
|
||||
if (!merged.tools && checkpoint?.tools) {
|
||||
merged.tools = checkpoint.tools
|
||||
}
|
||||
|
||||
return hasPartialAgentOrModel(merged) ? merged : null
|
||||
}
|
||||
|
||||
export function resolvePromptContextFromSessionMessages(
|
||||
messages: SessionMessage[],
|
||||
sessionID?: string,
|
||||
): StoredMessage | null {
|
||||
const convertedMessages = messages
|
||||
.map(convertSessionMessageToStoredMessage)
|
||||
.reverse()
|
||||
|
||||
return mergeStoredMessages(convertedMessages, sessionID)
|
||||
}
|
||||
|
||||
export function findNearestMessageExcludingCompaction(
|
||||
messageDir: string,
|
||||
sessionID?: string,
|
||||
): StoredMessage | null {
|
||||
try {
|
||||
const files = readdirSync(messageDir)
|
||||
.filter((name: string) => name.endsWith(".json"))
|
||||
.sort()
|
||||
.reverse()
|
||||
|
||||
const messages: Array<StoredMessage | null> = []
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = readFileSync(join(messageDir, file), "utf-8")
|
||||
messages.push(JSON.parse(content) as StoredMessage)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return mergeStoredMessages(messages, sessionID)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { tmpdir } from "node:os"
|
||||
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
import { BackgroundManager } from "./manager"
|
||||
|
||||
describe("BackgroundManager session permission", () => {
|
||||
test("passes explicit session permission rules to child session creation", async () => {
|
||||
// given
|
||||
const createCalls: Array<Record<string, unknown>> = []
|
||||
const client = {
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/parent" } }),
|
||||
create: async (input: Record<string, unknown>) => {
|
||||
createCalls.push(input)
|
||||
return { data: { id: "ses_child" } }
|
||||
},
|
||||
promptAsync: async () => ({}),
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
|
||||
|
||||
// when
|
||||
await manager.launch({
|
||||
description: "Test task",
|
||||
prompt: "Do something",
|
||||
agent: "explore",
|
||||
parentSessionID: "ses_parent",
|
||||
parentMessageID: "msg_parent",
|
||||
sessionPermission: [
|
||||
{ permission: "question", action: "deny", pattern: "*" },
|
||||
],
|
||||
})
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
manager.shutdown()
|
||||
|
||||
// then
|
||||
expect(createCalls).toHaveLength(1)
|
||||
expect(createCalls[0]?.body).toEqual({
|
||||
parentID: "ses_parent",
|
||||
title: "Test task (@explore subagent)",
|
||||
permission: [
|
||||
{ permission: "question", action: "deny", pattern: "*" },
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -224,6 +224,12 @@ function stubNotifyParentSession(manager: BackgroundManager): void {
|
||||
;(manager as unknown as { notifyParentSession: () => Promise<void> }).notifyParentSession = async () => {}
|
||||
}
|
||||
|
||||
async function flushBackgroundNotifications(): Promise<void> {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
function createToastRemoveTaskTracker(): { removeTaskCalls: string[]; resetToastManager: () => void } {
|
||||
_resetTaskToastManagerForTesting()
|
||||
const toastManager = initTaskToastManager({
|
||||
@@ -1306,11 +1312,20 @@ describe("BackgroundManager.tryCompleteTask", () => {
|
||||
expect(abortedSessionIDs).toEqual(["session-1"])
|
||||
})
|
||||
|
||||
test("should clean pendingByParent even when notifyParentSession throws", async () => {
|
||||
test("should clean pendingByParent even when promptAsync notification fails", async () => {
|
||||
// given
|
||||
;(manager as unknown as { notifyParentSession: () => Promise<void> }).notifyParentSession = async () => {
|
||||
throw new Error("notify failed")
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async () => ({}),
|
||||
promptAsync: async () => {
|
||||
throw new Error("notify failed")
|
||||
},
|
||||
abort: async () => ({}),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
|
||||
|
||||
const task: BackgroundTask = {
|
||||
id: "task-pending-cleanup",
|
||||
@@ -1518,7 +1533,7 @@ describe("BackgroundManager.tryCompleteTask", () => {
|
||||
// then
|
||||
expect(rejectedCount).toBe(0)
|
||||
expect(promptBodies.length).toBe(2)
|
||||
expect(promptBodies.some((b) => b.noReply === false)).toBe(true)
|
||||
expect(promptBodies.filter((body) => body.noReply === false)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1731,6 +1746,32 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
}
|
||||
}
|
||||
|
||||
function createMockClientWithSessionChain(
|
||||
sessions: Record<string, { directory: string; parentID?: string }>,
|
||||
options?: { sessionLookupError?: Error }
|
||||
) {
|
||||
return {
|
||||
session: {
|
||||
create: async (_args?: any) => ({ data: { id: `ses_${crypto.randomUUID()}` } }),
|
||||
get: async ({ path }: { path: { id: string } }) => {
|
||||
if (options?.sessionLookupError) {
|
||||
throw options.sessionLookupError
|
||||
}
|
||||
|
||||
return {
|
||||
data: sessions[path.id] ?? { directory: "/test/dir" },
|
||||
}
|
||||
},
|
||||
prompt: async () => ({}),
|
||||
promptAsync: async () => ({}),
|
||||
messages: async () => ({ data: [] }),
|
||||
todo: async () => ({ data: [] }),
|
||||
status: async () => ({ data: {} }),
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// given
|
||||
mockClient = createMockClient()
|
||||
@@ -1925,6 +1966,151 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
expect(updatedTask.startedAt.getTime()).toBeGreaterThanOrEqual(queuedAt.getTime())
|
||||
}
|
||||
})
|
||||
|
||||
test("should track rootSessionID and spawnDepth from the parent chain", async () => {
|
||||
// given
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager(
|
||||
{
|
||||
client: createMockClientWithSessionChain({
|
||||
"session-depth-2": { directory: "/test/dir", parentID: "session-depth-1" },
|
||||
"session-depth-1": { directory: "/test/dir", parentID: "session-root" },
|
||||
"session-root": { directory: "/test/dir" },
|
||||
}),
|
||||
directory: tmpdir(),
|
||||
} as unknown as PluginInput,
|
||||
{ maxDepth: 3 },
|
||||
)
|
||||
|
||||
const input = {
|
||||
description: "Test task",
|
||||
prompt: "Do something",
|
||||
agent: "test-agent",
|
||||
parentSessionID: "session-depth-2",
|
||||
parentMessageID: "parent-message",
|
||||
}
|
||||
|
||||
// when
|
||||
const task = await manager.launch(input)
|
||||
|
||||
// then
|
||||
expect(task.rootSessionID).toBe("session-root")
|
||||
expect(task.spawnDepth).toBe(3)
|
||||
})
|
||||
|
||||
test("should block launches that exceed maxDepth", async () => {
|
||||
// given
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager(
|
||||
{
|
||||
client: createMockClientWithSessionChain({
|
||||
"session-depth-3": { directory: "/test/dir", parentID: "session-depth-2" },
|
||||
"session-depth-2": { directory: "/test/dir", parentID: "session-depth-1" },
|
||||
"session-depth-1": { directory: "/test/dir", parentID: "session-root" },
|
||||
"session-root": { directory: "/test/dir" },
|
||||
}),
|
||||
directory: tmpdir(),
|
||||
} as unknown as PluginInput,
|
||||
{ maxDepth: 3 },
|
||||
)
|
||||
|
||||
const input = {
|
||||
description: "Test task",
|
||||
prompt: "Do something",
|
||||
agent: "test-agent",
|
||||
parentSessionID: "session-depth-3",
|
||||
parentMessageID: "parent-message",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = manager.launch(input)
|
||||
|
||||
// then
|
||||
await expect(result).rejects.toThrow("background_task.maxDepth=3")
|
||||
})
|
||||
|
||||
test("should block launches when maxDescendants is reached", async () => {
|
||||
// given
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager(
|
||||
{
|
||||
client: createMockClientWithSessionChain({
|
||||
"session-root": { directory: "/test/dir" },
|
||||
}),
|
||||
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)
|
||||
|
||||
// when
|
||||
const result = manager.launch(input)
|
||||
|
||||
// then
|
||||
await expect(result).rejects.toThrow("background_task.maxDescendants=1")
|
||||
})
|
||||
|
||||
test("should consume descendant quota for reserved sync spawns", async () => {
|
||||
// given
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager(
|
||||
{
|
||||
client: createMockClientWithSessionChain({
|
||||
"session-root": { directory: "/test/dir" },
|
||||
}),
|
||||
directory: tmpdir(),
|
||||
} as unknown as PluginInput,
|
||||
{ maxDescendants: 1 },
|
||||
)
|
||||
|
||||
await manager.reserveSubagentSpawn("session-root")
|
||||
|
||||
// when
|
||||
const result = manager.assertCanSpawn("session-root")
|
||||
|
||||
// then
|
||||
await expect(result).rejects.toThrow("background_task.maxDescendants=1")
|
||||
})
|
||||
|
||||
test("should fail closed when session lineage lookup fails", async () => {
|
||||
// given
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager(
|
||||
{
|
||||
client: createMockClientWithSessionChain(
|
||||
{
|
||||
"session-root": { directory: "/test/dir" },
|
||||
},
|
||||
{ sessionLookupError: new Error("session lookup failed") }
|
||||
),
|
||||
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",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = manager.launch(input)
|
||||
|
||||
// then
|
||||
await expect(result).rejects.toThrow("background_task.maxDescendants cannot be enforced safely")
|
||||
})
|
||||
})
|
||||
|
||||
describe("pending task can be cancelled", () => {
|
||||
@@ -2026,7 +2212,6 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
test("should cancel running task and release concurrency", async () => {
|
||||
// given
|
||||
const manager = createBackgroundManager()
|
||||
stubNotifyParentSession(manager)
|
||||
|
||||
const concurrencyManager = getConcurrencyManager(manager)
|
||||
const concurrencyKey = "test-provider/test-model"
|
||||
@@ -2984,7 +3169,7 @@ describe("BackgroundManager.shutdown session abort", () => {
|
||||
})
|
||||
|
||||
describe("BackgroundManager.handleEvent - session.deleted cascade", () => {
|
||||
test("should cancel descendant tasks when parent session is deleted", () => {
|
||||
test("should cancel descendant tasks and keep them until delayed cleanup", async () => {
|
||||
// given
|
||||
const manager = createBackgroundManager()
|
||||
const parentSessionID = "session-parent"
|
||||
@@ -3031,21 +3216,26 @@ describe("BackgroundManager.handleEvent - session.deleted cascade", () => {
|
||||
properties: { info: { id: parentSessionID } },
|
||||
})
|
||||
|
||||
await flushBackgroundNotifications()
|
||||
|
||||
// then
|
||||
expect(taskMap.has(childTask.id)).toBe(false)
|
||||
expect(taskMap.has(siblingTask.id)).toBe(false)
|
||||
expect(taskMap.has(grandchildTask.id)).toBe(false)
|
||||
expect(taskMap.has(childTask.id)).toBe(true)
|
||||
expect(taskMap.has(siblingTask.id)).toBe(true)
|
||||
expect(taskMap.has(grandchildTask.id)).toBe(true)
|
||||
expect(taskMap.has(unrelatedTask.id)).toBe(true)
|
||||
expect(childTask.status).toBe("cancelled")
|
||||
expect(siblingTask.status).toBe("cancelled")
|
||||
expect(grandchildTask.status).toBe("cancelled")
|
||||
expect(pendingByParent.get(parentSessionID)).toBeUndefined()
|
||||
expect(pendingByParent.get("session-child")).toBeUndefined()
|
||||
expect(getCompletionTimers(manager).has(childTask.id)).toBe(true)
|
||||
expect(getCompletionTimers(manager).has(siblingTask.id)).toBe(true)
|
||||
expect(getCompletionTimers(manager).has(grandchildTask.id)).toBe(true)
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
test("should remove tasks from toast manager when session is deleted", () => {
|
||||
test("should remove cancelled tasks from toast manager while preserving delayed cleanup", async () => {
|
||||
//#given
|
||||
const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker()
|
||||
const manager = createBackgroundManager()
|
||||
@@ -3074,9 +3264,13 @@ describe("BackgroundManager.handleEvent - session.deleted cascade", () => {
|
||||
properties: { info: { id: parentSessionID } },
|
||||
})
|
||||
|
||||
await flushBackgroundNotifications()
|
||||
|
||||
//#then
|
||||
expect(removeTaskCalls).toContain(childTask.id)
|
||||
expect(removeTaskCalls).toContain(grandchildTask.id)
|
||||
expect(getCompletionTimers(manager).has(childTask.id)).toBe(true)
|
||||
expect(getCompletionTimers(manager).has(grandchildTask.id)).toBe(true)
|
||||
|
||||
manager.shutdown()
|
||||
resetToastManager()
|
||||
@@ -3139,7 +3333,7 @@ describe("BackgroundManager.handleEvent - session.error", () => {
|
||||
return task
|
||||
}
|
||||
|
||||
test("sets task to error, releases concurrency, and cleans up", async () => {
|
||||
test("sets task to error, releases concurrency, and keeps it until delayed cleanup", async () => {
|
||||
//#given
|
||||
const manager = createBackgroundManager()
|
||||
const concurrencyManager = getConcurrencyManager(manager)
|
||||
@@ -3172,18 +3366,21 @@ describe("BackgroundManager.handleEvent - session.error", () => {
|
||||
},
|
||||
})
|
||||
|
||||
await flushBackgroundNotifications()
|
||||
|
||||
//#then
|
||||
expect(task.status).toBe("error")
|
||||
expect(task.error).toBe("Model not found: kimi-for-coding/k2p5.")
|
||||
expect(task.completedAt).toBeInstanceOf(Date)
|
||||
expect(concurrencyManager.getCount(concurrencyKey)).toBe(0)
|
||||
expect(getTaskMap(manager).has(task.id)).toBe(false)
|
||||
expect(getTaskMap(manager).has(task.id)).toBe(true)
|
||||
expect(getPendingByParent(manager).get(task.parentSessionID)).toBeUndefined()
|
||||
expect(getCompletionTimers(manager).has(task.id)).toBe(true)
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
test("removes errored task from toast manager", () => {
|
||||
test("should remove errored task from toast manager while preserving delayed cleanup", async () => {
|
||||
//#given
|
||||
const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker()
|
||||
const manager = createBackgroundManager()
|
||||
@@ -3205,8 +3402,11 @@ describe("BackgroundManager.handleEvent - session.error", () => {
|
||||
},
|
||||
})
|
||||
|
||||
await flushBackgroundNotifications()
|
||||
|
||||
//#then
|
||||
expect(removeTaskCalls).toContain(task.id)
|
||||
expect(getCompletionTimers(manager).has(task.id)).toBe(true)
|
||||
|
||||
manager.shutdown()
|
||||
resetToastManager()
|
||||
@@ -3489,7 +3689,7 @@ describe("BackgroundManager.pruneStaleTasksAndNotifications - removes pruned tas
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
test("removes stale task from toast manager", () => {
|
||||
test("removes stale task from toast manager", async () => {
|
||||
//#given
|
||||
const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker()
|
||||
const manager = createBackgroundManager()
|
||||
@@ -3504,6 +3704,7 @@ describe("BackgroundManager.pruneStaleTasksAndNotifications - removes pruned tas
|
||||
|
||||
//#when
|
||||
pruneStaleTasksAndNotificationsForTest(manager)
|
||||
await flushBackgroundNotifications()
|
||||
|
||||
//#then
|
||||
expect(removeTaskCalls).toContain(staleTask.id)
|
||||
@@ -3511,6 +3712,53 @@ describe("BackgroundManager.pruneStaleTasksAndNotifications - removes pruned tas
|
||||
manager.shutdown()
|
||||
resetToastManager()
|
||||
})
|
||||
|
||||
test("keeps stale task until notification cleanup after notifying parent", async () => {
|
||||
//#given
|
||||
const notifications: string[] = []
|
||||
const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker()
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async () => ({}),
|
||||
promptAsync: async (args: { path: { id: string }; body: Record<string, unknown> & { noReply?: boolean; parts?: unknown[] } }) => {
|
||||
const firstPart = args.body.parts?.[0]
|
||||
if (firstPart && typeof firstPart === "object" && "text" in firstPart && typeof firstPart.text === "string") {
|
||||
notifications.push(firstPart.text)
|
||||
}
|
||||
return {}
|
||||
},
|
||||
abort: async () => ({}),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
|
||||
const staleTask = createMockTask({
|
||||
id: "task-stale-notify-cleanup",
|
||||
sessionID: "session-stale-notify-cleanup",
|
||||
parentSessionID: "parent-stale-notify-cleanup",
|
||||
status: "running",
|
||||
startedAt: new Date(Date.now() - 31 * 60 * 1000),
|
||||
})
|
||||
getTaskMap(manager).set(staleTask.id, staleTask)
|
||||
getPendingByParent(manager).set(staleTask.parentSessionID, new Set([staleTask.id]))
|
||||
|
||||
//#when
|
||||
pruneStaleTasksAndNotificationsForTest(manager)
|
||||
await flushBackgroundNotifications()
|
||||
|
||||
//#then
|
||||
const retainedTask = getTaskMap(manager).get(staleTask.id)
|
||||
expect(retainedTask?.status).toBe("error")
|
||||
expect(getTaskMap(manager).has(staleTask.id)).toBe(true)
|
||||
expect(notifications).toHaveLength(1)
|
||||
expect(notifications[0]).toContain("[ALL BACKGROUND TASKS COMPLETE]")
|
||||
expect(notifications[0]).toContain(staleTask.description)
|
||||
expect(getCompletionTimers(manager).has(staleTask.id)).toBe(true)
|
||||
expect(removeTaskCalls).toContain(staleTask.id)
|
||||
|
||||
manager.shutdown()
|
||||
resetToastManager()
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager.completionTimers - Memory Leak Fix", () => {
|
||||
@@ -3614,7 +3862,7 @@ describe("BackgroundManager.completionTimers - Memory Leak Fix", () => {
|
||||
expect(completionTimers.size).toBe(0)
|
||||
})
|
||||
|
||||
test("should cancel timer when task is deleted via session.deleted", () => {
|
||||
test("should preserve cleanup timer when terminal task session is deleted", () => {
|
||||
// given
|
||||
const manager = createBackgroundManager()
|
||||
const task: BackgroundTask = {
|
||||
@@ -3643,7 +3891,7 @@ describe("BackgroundManager.completionTimers - Memory Leak Fix", () => {
|
||||
})
|
||||
|
||||
// then
|
||||
expect(completionTimers.has(task.id)).toBe(false)
|
||||
expect(completionTimers.has(task.id)).toBe(true)
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
@@ -41,13 +41,24 @@ import {
|
||||
} from "./error-classifier"
|
||||
import { tryFallbackRetry } from "./fallback-retry-handler"
|
||||
import { registerManagerForCleanup, unregisterManagerForCleanup } from "./process-cleanup"
|
||||
import { isCompactionAgent, findNearestMessageExcludingCompaction } from "./compaction-aware-message-resolver"
|
||||
import {
|
||||
findNearestMessageExcludingCompaction,
|
||||
resolvePromptContextFromSessionMessages,
|
||||
} from "./compaction-aware-message-resolver"
|
||||
import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler"
|
||||
import { MESSAGE_STORAGE } from "../hook-message-injector"
|
||||
import { join } from "node:path"
|
||||
import { pruneStaleTasksAndNotifications } from "./task-poller"
|
||||
import { checkAndInterruptStaleTasks } from "./task-poller"
|
||||
import { removeTaskToastTracking } from "./remove-task-toast-tracking"
|
||||
import {
|
||||
createSubagentDepthLimitError,
|
||||
createSubagentDescendantLimitError,
|
||||
getMaxRootSessionSpawnBudget,
|
||||
getMaxSubagentDepth,
|
||||
resolveSubagentSpawnContext,
|
||||
type SubagentSpawnContext,
|
||||
} from "./subagent-spawn-limits"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
@@ -112,6 +123,7 @@ export class BackgroundManager {
|
||||
private completionTimers: 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 rootDescendantCounts: Map<string, number>
|
||||
private enableParentSessionNotifications: boolean
|
||||
readonly taskHistory = new TaskHistory()
|
||||
|
||||
@@ -136,10 +148,77 @@ export class BackgroundManager {
|
||||
this.tmuxEnabled = options?.tmuxConfig?.enabled ?? false
|
||||
this.onSubagentSessionCreated = options?.onSubagentSessionCreated
|
||||
this.onShutdown = options?.onShutdown
|
||||
this.rootDescendantCounts = new Map()
|
||||
this.enableParentSessionNotifications = options?.enableParentSessionNotifications ?? true
|
||||
this.registerProcessCleanup()
|
||||
}
|
||||
|
||||
async assertCanSpawn(parentSessionID: string): Promise<SubagentSpawnContext> {
|
||||
const spawnContext = await resolveSubagentSpawnContext(this.client, parentSessionID)
|
||||
const maxDepth = getMaxSubagentDepth(this.config)
|
||||
if (spawnContext.childDepth > maxDepth) {
|
||||
throw createSubagentDepthLimitError({
|
||||
childDepth: spawnContext.childDepth,
|
||||
maxDepth,
|
||||
parentSessionID,
|
||||
rootSessionID: spawnContext.rootSessionID,
|
||||
})
|
||||
}
|
||||
|
||||
const maxRootSessionSpawnBudget = getMaxRootSessionSpawnBudget(this.config)
|
||||
const descendantCount = this.rootDescendantCounts.get(spawnContext.rootSessionID) ?? 0
|
||||
if (descendantCount >= maxRootSessionSpawnBudget) {
|
||||
throw createSubagentDescendantLimitError({
|
||||
rootSessionID: spawnContext.rootSessionID,
|
||||
descendantCount,
|
||||
maxDescendants: maxRootSessionSpawnBudget,
|
||||
})
|
||||
}
|
||||
|
||||
return spawnContext
|
||||
}
|
||||
|
||||
async reserveSubagentSpawn(parentSessionID: string): Promise<{
|
||||
spawnContext: SubagentSpawnContext
|
||||
descendantCount: number
|
||||
commit: () => number
|
||||
rollback: () => void
|
||||
}> {
|
||||
const spawnContext = await this.assertCanSpawn(parentSessionID)
|
||||
const descendantCount = this.registerRootDescendant(spawnContext.rootSessionID)
|
||||
let settled = false
|
||||
|
||||
return {
|
||||
spawnContext,
|
||||
descendantCount,
|
||||
commit: () => {
|
||||
settled = true
|
||||
return descendantCount
|
||||
},
|
||||
rollback: () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
this.unregisterRootDescendant(spawnContext.rootSessionID)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private registerRootDescendant(rootSessionID: string): number {
|
||||
const nextCount = (this.rootDescendantCounts.get(rootSessionID) ?? 0) + 1
|
||||
this.rootDescendantCounts.set(rootSessionID, nextCount)
|
||||
return nextCount
|
||||
}
|
||||
|
||||
private unregisterRootDescendant(rootSessionID: string): void {
|
||||
const currentCount = this.rootDescendantCounts.get(rootSessionID) ?? 0
|
||||
if (currentCount <= 1) {
|
||||
this.rootDescendantCounts.delete(rootSessionID)
|
||||
return
|
||||
}
|
||||
|
||||
this.rootDescendantCounts.set(rootSessionID, currentCount - 1)
|
||||
}
|
||||
|
||||
async launch(input: LaunchInput): Promise<BackgroundTask> {
|
||||
log("[background-agent] launch() called with:", {
|
||||
agent: input.agent,
|
||||
@@ -152,61 +231,79 @@ export class BackgroundManager {
|
||||
throw new Error("Agent parameter is required")
|
||||
}
|
||||
|
||||
// Create task immediately with status="pending"
|
||||
const task: BackgroundTask = {
|
||||
id: `bg_${crypto.randomUUID().slice(0, 8)}`,
|
||||
status: "pending",
|
||||
queuedAt: new Date(),
|
||||
// Do NOT set startedAt - will be set when running
|
||||
// Do NOT set sessionID - will be set when running
|
||||
description: input.description,
|
||||
prompt: input.prompt,
|
||||
agent: input.agent,
|
||||
parentSessionID: input.parentSessionID,
|
||||
parentMessageID: input.parentMessageID,
|
||||
parentModel: input.parentModel,
|
||||
parentAgent: input.parentAgent,
|
||||
parentTools: input.parentTools,
|
||||
model: input.model,
|
||||
fallbackChain: input.fallbackChain,
|
||||
attemptCount: 0,
|
||||
category: input.category,
|
||||
}
|
||||
const spawnReservation = await this.reserveSubagentSpawn(input.parentSessionID)
|
||||
|
||||
this.tasks.set(task.id, task)
|
||||
this.taskHistory.record(input.parentSessionID, { id: task.id, agent: input.agent, description: input.description, status: "pending", category: input.category })
|
||||
|
||||
// Track for batched notifications immediately (pending state)
|
||||
if (input.parentSessionID) {
|
||||
const pending = this.pendingByParent.get(input.parentSessionID) ?? new Set()
|
||||
pending.add(task.id)
|
||||
this.pendingByParent.set(input.parentSessionID, pending)
|
||||
}
|
||||
|
||||
// Add to queue
|
||||
const key = this.getConcurrencyKeyFromInput(input)
|
||||
const queue = this.queuesByKey.get(key) ?? []
|
||||
queue.push({ task, input })
|
||||
this.queuesByKey.set(key, queue)
|
||||
|
||||
log("[background-agent] Task queued:", { taskId: task.id, key, queueLength: queue.length })
|
||||
|
||||
const toastManager = getTaskToastManager()
|
||||
if (toastManager) {
|
||||
toastManager.addTask({
|
||||
id: task.id,
|
||||
description: input.description,
|
||||
agent: input.agent,
|
||||
isBackground: true,
|
||||
status: "queued",
|
||||
skills: input.skills,
|
||||
try {
|
||||
log("[background-agent] spawn guard passed", {
|
||||
parentSessionID: input.parentSessionID,
|
||||
rootSessionID: spawnReservation.spawnContext.rootSessionID,
|
||||
childDepth: spawnReservation.spawnContext.childDepth,
|
||||
descendantCount: spawnReservation.descendantCount,
|
||||
})
|
||||
|
||||
// Create task immediately with status="pending"
|
||||
const task: BackgroundTask = {
|
||||
id: `bg_${crypto.randomUUID().slice(0, 8)}`,
|
||||
status: "pending",
|
||||
queuedAt: new Date(),
|
||||
rootSessionID: spawnReservation.spawnContext.rootSessionID,
|
||||
// Do NOT set startedAt - will be set when running
|
||||
// Do NOT set sessionID - will be set when running
|
||||
description: input.description,
|
||||
prompt: input.prompt,
|
||||
agent: input.agent,
|
||||
spawnDepth: spawnReservation.spawnContext.childDepth,
|
||||
parentSessionID: input.parentSessionID,
|
||||
parentMessageID: input.parentMessageID,
|
||||
parentModel: input.parentModel,
|
||||
parentAgent: input.parentAgent,
|
||||
parentTools: input.parentTools,
|
||||
model: input.model,
|
||||
fallbackChain: input.fallbackChain,
|
||||
attemptCount: 0,
|
||||
category: input.category,
|
||||
}
|
||||
|
||||
this.tasks.set(task.id, task)
|
||||
this.taskHistory.record(input.parentSessionID, { id: task.id, agent: input.agent, description: input.description, status: "pending", category: input.category })
|
||||
|
||||
// Track for batched notifications immediately (pending state)
|
||||
if (input.parentSessionID) {
|
||||
const pending = this.pendingByParent.get(input.parentSessionID) ?? new Set()
|
||||
pending.add(task.id)
|
||||
this.pendingByParent.set(input.parentSessionID, pending)
|
||||
}
|
||||
|
||||
// Add to queue
|
||||
const key = this.getConcurrencyKeyFromInput(input)
|
||||
const queue = this.queuesByKey.get(key) ?? []
|
||||
queue.push({ task, input })
|
||||
this.queuesByKey.set(key, queue)
|
||||
|
||||
log("[background-agent] Task queued:", { taskId: task.id, key, queueLength: queue.length })
|
||||
|
||||
const toastManager = getTaskToastManager()
|
||||
if (toastManager) {
|
||||
toastManager.addTask({
|
||||
id: task.id,
|
||||
description: input.description,
|
||||
agent: input.agent,
|
||||
isBackground: true,
|
||||
status: "queued",
|
||||
skills: input.skills,
|
||||
})
|
||||
}
|
||||
|
||||
spawnReservation.commit()
|
||||
|
||||
// Trigger processing (fire-and-forget)
|
||||
this.processKey(key)
|
||||
|
||||
return { ...task }
|
||||
} catch (error) {
|
||||
spawnReservation.rollback()
|
||||
throw error
|
||||
}
|
||||
|
||||
// Trigger processing (fire-and-forget)
|
||||
this.processKey(key)
|
||||
|
||||
return task
|
||||
}
|
||||
|
||||
private async processKey(key: string): Promise<void> {
|
||||
@@ -272,6 +369,7 @@ export class BackgroundManager {
|
||||
body: {
|
||||
parentID: input.parentSessionID,
|
||||
title: `${input.description} (@${input.agent} subagent)`,
|
||||
...(input.sessionPermission ? { permission: input.sessionPermission } : {}),
|
||||
} as Record<string, unknown>,
|
||||
query: {
|
||||
directory: parentDirectory,
|
||||
@@ -394,7 +492,6 @@ export class BackgroundManager {
|
||||
}).catch(() => {})
|
||||
|
||||
this.markForNotification(existingTask)
|
||||
this.cleanupPendingByParent(existingTask)
|
||||
this.enqueueNotificationForParent(existingTask.parentSessionID, () => this.notifyParentSession(existingTask)).catch(err => {
|
||||
log("[background-agent] Failed to notify on error:", err)
|
||||
})
|
||||
@@ -667,7 +764,6 @@ export class BackgroundManager {
|
||||
}
|
||||
|
||||
this.markForNotification(existingTask)
|
||||
this.cleanupPendingByParent(existingTask)
|
||||
this.enqueueNotificationForParent(existingTask.parentSessionID, () => this.notifyParentSession(existingTask)).catch(err => {
|
||||
log("[background-agent] Failed to notify on resume error:", err)
|
||||
})
|
||||
@@ -810,16 +906,14 @@ export class BackgroundManager {
|
||||
this.idleDeferralTimers.delete(task.id)
|
||||
}
|
||||
|
||||
this.cleanupPendingByParent(task)
|
||||
this.tasks.delete(task.id)
|
||||
this.clearNotificationsForTask(task.id)
|
||||
const toastManager = getTaskToastManager()
|
||||
if (toastManager) {
|
||||
toastManager.removeTask(task.id)
|
||||
}
|
||||
if (task.sessionID) {
|
||||
subagentSessions.delete(task.sessionID)
|
||||
SessionCategoryRegistry.remove(task.sessionID)
|
||||
}
|
||||
|
||||
this.markForNotification(task)
|
||||
this.enqueueNotificationForParent(task.parentSessionID, () => this.notifyParentSession(task)).catch(err => {
|
||||
log("[background-agent] Error in notifyParentSession for errored task:", { taskId: task.id, error: err })
|
||||
})
|
||||
}
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
@@ -840,47 +934,32 @@ export class BackgroundManager {
|
||||
|
||||
if (tasksToCancel.size === 0) return
|
||||
|
||||
const deletedSessionIDs = new Set<string>([sessionID])
|
||||
for (const task of tasksToCancel.values()) {
|
||||
if (task.sessionID) {
|
||||
deletedSessionIDs.add(task.sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
for (const task of tasksToCancel.values()) {
|
||||
if (task.status === "running" || task.status === "pending") {
|
||||
void this.cancelTask(task.id, {
|
||||
source: "session.deleted",
|
||||
reason: "Session deleted",
|
||||
skipNotification: true,
|
||||
}).then(() => {
|
||||
if (deletedSessionIDs.has(task.parentSessionID)) {
|
||||
this.pendingNotifications.delete(task.parentSessionID)
|
||||
}
|
||||
}).catch(err => {
|
||||
if (deletedSessionIDs.has(task.parentSessionID)) {
|
||||
this.pendingNotifications.delete(task.parentSessionID)
|
||||
}
|
||||
log("[background-agent] Failed to cancel task on session.deleted:", { taskId: task.id, error: err })
|
||||
})
|
||||
}
|
||||
|
||||
const existingTimer = this.completionTimers.get(task.id)
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer)
|
||||
this.completionTimers.delete(task.id)
|
||||
}
|
||||
|
||||
const idleTimer = this.idleDeferralTimers.get(task.id)
|
||||
if (idleTimer) {
|
||||
clearTimeout(idleTimer)
|
||||
this.idleDeferralTimers.delete(task.id)
|
||||
}
|
||||
|
||||
this.cleanupPendingByParent(task)
|
||||
this.tasks.delete(task.id)
|
||||
this.clearNotificationsForTask(task.id)
|
||||
const toastManager = getTaskToastManager()
|
||||
if (toastManager) {
|
||||
toastManager.removeTask(task.id)
|
||||
}
|
||||
if (task.sessionID) {
|
||||
subagentSessions.delete(task.sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
for (const task of tasksToCancel.values()) {
|
||||
if (task.parentSessionID) {
|
||||
this.pendingNotifications.delete(task.parentSessionID)
|
||||
}
|
||||
}
|
||||
|
||||
this.rootDescendantCounts.delete(sessionID)
|
||||
SessionCategoryRegistry.remove(sessionID)
|
||||
}
|
||||
|
||||
@@ -1100,8 +1179,6 @@ export class BackgroundManager {
|
||||
this.idleDeferralTimers.delete(task.id)
|
||||
}
|
||||
|
||||
this.cleanupPendingByParent(task)
|
||||
|
||||
if (abortSession && task.sessionID) {
|
||||
this.client.session.abort({
|
||||
path: { id: task.sessionID },
|
||||
@@ -1208,9 +1285,6 @@ export class BackgroundManager {
|
||||
|
||||
this.markForNotification(task)
|
||||
|
||||
// Ensure pending tracking is cleaned up even if notification fails
|
||||
this.cleanupPendingByParent(task)
|
||||
|
||||
const idleTimer = this.idleDeferralTimers.get(task.id)
|
||||
if (idleTimer) {
|
||||
clearTimeout(idleTimer)
|
||||
@@ -1266,7 +1340,10 @@ export class BackgroundManager {
|
||||
this.pendingByParent.delete(task.parentSessionID)
|
||||
}
|
||||
} else {
|
||||
allComplete = true
|
||||
remainingCount = Array.from(this.tasks.values())
|
||||
.filter(t => t.parentSessionID === task.parentSessionID && t.id !== task.id && (t.status === "running" || t.status === "pending"))
|
||||
.length
|
||||
allComplete = remainingCount === 0
|
||||
}
|
||||
|
||||
const completedTasks = allComplete
|
||||
@@ -1274,7 +1351,13 @@ export class BackgroundManager {
|
||||
.filter(t => t.parentSessionID === task.parentSessionID && t.status !== "running" && t.status !== "pending")
|
||||
: []
|
||||
|
||||
const statusText = task.status === "completed" ? "COMPLETED" : task.status === "interrupt" ? "INTERRUPTED" : "CANCELLED"
|
||||
const statusText = task.status === "completed"
|
||||
? "COMPLETED"
|
||||
: task.status === "interrupt"
|
||||
? "INTERRUPTED"
|
||||
: task.status === "error"
|
||||
? "ERROR"
|
||||
: "CANCELLED"
|
||||
const errorInfo = task.error ? `\n**Error:** ${task.error}` : ""
|
||||
|
||||
let notification: string
|
||||
@@ -1322,20 +1405,20 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
tools?: Record<string, boolean | "allow" | "deny" | "ask">
|
||||
}
|
||||
}>)
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const info = messages[i].info
|
||||
if (isCompactionAgent(info?.agent)) {
|
||||
continue
|
||||
}
|
||||
const normalizedTools = isRecord(info?.tools)
|
||||
? normalizePromptTools(info.tools as Record<string, boolean | "allow" | "deny" | "ask">)
|
||||
const promptContext = resolvePromptContextFromSessionMessages(
|
||||
messages,
|
||||
task.parentSessionID,
|
||||
)
|
||||
const normalizedTools = isRecord(promptContext?.tools)
|
||||
? normalizePromptTools(promptContext.tools)
|
||||
: undefined
|
||||
|
||||
if (promptContext?.agent || promptContext?.model || normalizedTools) {
|
||||
agent = promptContext?.agent ?? task.parentAgent
|
||||
model = promptContext?.model?.providerID && promptContext.model.modelID
|
||||
? { providerID: promptContext.model.providerID, modelID: promptContext.model.modelID }
|
||||
: undefined
|
||||
if (info?.agent || info?.model || (info?.modelID && info?.providerID) || normalizedTools) {
|
||||
agent = info?.agent ?? task.parentAgent
|
||||
model = info?.model ?? (info?.providerID && info?.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined)
|
||||
tools = normalizedTools ?? tools
|
||||
break
|
||||
}
|
||||
tools = normalizedTools ?? tools
|
||||
}
|
||||
} catch (error) {
|
||||
if (isAbortedSessionError(error)) {
|
||||
@@ -1345,7 +1428,9 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
})
|
||||
}
|
||||
const messageDir = join(MESSAGE_STORAGE, task.parentSessionID)
|
||||
const currentMessage = messageDir ? findNearestMessageExcludingCompaction(messageDir) : null
|
||||
const currentMessage = messageDir
|
||||
? findNearestMessageExcludingCompaction(messageDir, task.parentSessionID)
|
||||
: null
|
||||
agent = currentMessage?.agent ?? task.parentAgent
|
||||
model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
|
||||
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
|
||||
@@ -1405,8 +1490,13 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
this.completionTimers.delete(taskId)
|
||||
if (this.tasks.has(taskId)) {
|
||||
const taskToRemove = this.tasks.get(taskId)
|
||||
if (taskToRemove) {
|
||||
this.clearNotificationsForTask(taskId)
|
||||
if (taskToRemove.sessionID) {
|
||||
subagentSessions.delete(taskToRemove.sessionID)
|
||||
SessionCategoryRegistry.remove(taskToRemove.sessionID)
|
||||
}
|
||||
this.tasks.delete(taskId)
|
||||
log("[background-agent] Removed completed task from memory:", taskId)
|
||||
}
|
||||
@@ -1416,14 +1506,6 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
}
|
||||
}
|
||||
|
||||
private formatDuration(start: Date, end?: Date): string {
|
||||
return formatDuration(start, end)
|
||||
}
|
||||
|
||||
private isAbortedSessionError(error: unknown): boolean {
|
||||
return isAbortedSessionError(error)
|
||||
}
|
||||
|
||||
private hasRunningTasks(): boolean {
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.status === "running") return true
|
||||
@@ -1441,12 +1523,22 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
task.status = "error"
|
||||
task.error = errorMessage
|
||||
task.completedAt = new Date()
|
||||
this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "error", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt })
|
||||
if (task.concurrencyKey) {
|
||||
this.concurrencyManager.release(task.concurrencyKey)
|
||||
task.concurrencyKey = undefined
|
||||
}
|
||||
removeTaskToastTracking(task.id)
|
||||
this.cleanupPendingByParent(task)
|
||||
const existingTimer = this.completionTimers.get(taskId)
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer)
|
||||
this.completionTimers.delete(taskId)
|
||||
}
|
||||
const idleTimer = this.idleDeferralTimers.get(taskId)
|
||||
if (idleTimer) {
|
||||
clearTimeout(idleTimer)
|
||||
this.idleDeferralTimers.delete(taskId)
|
||||
}
|
||||
if (wasPending) {
|
||||
const key = task.model
|
||||
? `${task.model.providerID}/${task.model.modelID}`
|
||||
@@ -1462,16 +1554,10 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
}
|
||||
}
|
||||
}
|
||||
this.clearNotificationsForTask(taskId)
|
||||
const toastManager = getTaskToastManager()
|
||||
if (toastManager) {
|
||||
toastManager.removeTask(taskId)
|
||||
}
|
||||
this.tasks.delete(taskId)
|
||||
if (task.sessionID) {
|
||||
subagentSessions.delete(task.sessionID)
|
||||
SessionCategoryRegistry.remove(task.sessionID)
|
||||
}
|
||||
this.markForNotification(task)
|
||||
this.enqueueNotificationForParent(task.parentSessionID, () => this.notifyParentSession(task)).catch(err => {
|
||||
log("[background-agent] Error in notifyParentSession for stale-pruned task:", { taskId: task.id, error: err })
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1619,6 +1705,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
this.pendingNotifications.clear()
|
||||
this.pendingByParent.clear()
|
||||
this.notificationQueueByParent.clear()
|
||||
this.rootDescendantCounts.clear()
|
||||
this.queuesByKey.clear()
|
||||
this.processingKeys.clear()
|
||||
this.unregisterProcessCleanup()
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, test, expect } from "bun:test"
|
||||
import { createTask, startTask } from "./spawner"
|
||||
|
||||
describe("background-agent spawner.startTask", () => {
|
||||
test("does not override parent session permission rules when creating child session", async () => {
|
||||
test("applies explicit child session permission rules when creating child session", async () => {
|
||||
//#given
|
||||
const createCalls: any[] = []
|
||||
const parentPermission = [
|
||||
@@ -41,6 +41,9 @@ describe("background-agent spawner.startTask", () => {
|
||||
parentModel: task.parentModel,
|
||||
parentAgent: task.parentAgent,
|
||||
model: task.model,
|
||||
sessionPermission: [
|
||||
{ permission: "question", action: "deny", pattern: "*" },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -57,6 +60,8 @@ describe("background-agent spawner.startTask", () => {
|
||||
|
||||
//#then
|
||||
expect(createCalls).toHaveLength(1)
|
||||
expect(createCalls[0]?.body?.permission).toBeUndefined()
|
||||
expect(createCalls[0]?.body?.permission).toEqual([
|
||||
{ permission: "question", action: "deny", pattern: "*" },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -61,6 +61,7 @@ export async function startTask(
|
||||
const createResult = await client.session.create({
|
||||
body: {
|
||||
parentID: input.parentSessionID,
|
||||
...(input.sessionPermission ? { permission: input.sessionPermission } : {}),
|
||||
} as Record<string, unknown>,
|
||||
query: {
|
||||
directory: parentDirectory,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpencodeClient } from "./constants"
|
||||
import { resolveSubagentSpawnContext } from "./subagent-spawn-limits"
|
||||
|
||||
function createMockClient(sessionGet: OpencodeClient["session"]["get"]): OpencodeClient {
|
||||
return {
|
||||
session: {
|
||||
get: sessionGet,
|
||||
},
|
||||
} as OpencodeClient
|
||||
}
|
||||
|
||||
describe("resolveSubagentSpawnContext", () => {
|
||||
describe("#given session.get returns an SDK error response", () => {
|
||||
test("throws a fail-closed spawn blocked error", async () => {
|
||||
// given
|
||||
const client = createMockClient(async () => ({
|
||||
error: "lookup failed",
|
||||
data: undefined,
|
||||
}))
|
||||
|
||||
// when
|
||||
const result = resolveSubagentSpawnContext(client, "parent-session")
|
||||
|
||||
// then
|
||||
await expect(result).rejects.toThrow(/background_task\.maxDescendants cannot be enforced safely.*lookup failed/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given session.get returns no session data", () => {
|
||||
test("throws a fail-closed spawn blocked error", async () => {
|
||||
// given
|
||||
const client = createMockClient(async () => ({
|
||||
data: undefined,
|
||||
}))
|
||||
|
||||
// when
|
||||
const result = resolveSubagentSpawnContext(client, "parent-session")
|
||||
|
||||
// then
|
||||
await expect(result).rejects.toThrow(/background_task\.maxDescendants cannot be enforced safely.*No session data returned/)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { BackgroundTaskConfig } from "../../config/schema"
|
||||
import type { OpencodeClient } from "./constants"
|
||||
|
||||
export const DEFAULT_MAX_SUBAGENT_DEPTH = 3
|
||||
export const DEFAULT_MAX_ROOT_SESSION_SPAWN_BUDGET = 50
|
||||
|
||||
export interface SubagentSpawnContext {
|
||||
rootSessionID: string
|
||||
parentDepth: number
|
||||
childDepth: number
|
||||
}
|
||||
|
||||
export function getMaxSubagentDepth(config?: BackgroundTaskConfig): number {
|
||||
return config?.maxDepth ?? DEFAULT_MAX_SUBAGENT_DEPTH
|
||||
}
|
||||
|
||||
export function getMaxRootSessionSpawnBudget(config?: BackgroundTaskConfig): number {
|
||||
return config?.maxDescendants ?? DEFAULT_MAX_ROOT_SESSION_SPAWN_BUDGET
|
||||
}
|
||||
|
||||
export async function resolveSubagentSpawnContext(
|
||||
client: OpencodeClient,
|
||||
parentSessionID: string
|
||||
): Promise<SubagentSpawnContext> {
|
||||
const visitedSessionIDs = new Set<string>()
|
||||
let rootSessionID = parentSessionID
|
||||
let currentSessionID = parentSessionID
|
||||
let parentDepth = 0
|
||||
|
||||
while (true) {
|
||||
if (visitedSessionIDs.has(currentSessionID)) {
|
||||
throw new Error(`Detected a session parent cycle while resolving ${parentSessionID}`)
|
||||
}
|
||||
|
||||
visitedSessionIDs.add(currentSessionID)
|
||||
|
||||
let nextParentSessionID: string | undefined
|
||||
try {
|
||||
const response = await client.session.get({
|
||||
path: { id: currentSessionID },
|
||||
})
|
||||
if (response.error) {
|
||||
throw new Error(String(response.error))
|
||||
}
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error("No session data returned")
|
||||
}
|
||||
|
||||
nextParentSessionID = response.data.parentID
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(
|
||||
`Subagent spawn blocked: failed to resolve session lineage for ${parentSessionID}, so background_task.maxDescendants cannot be enforced safely. ${reason}`
|
||||
)
|
||||
}
|
||||
|
||||
if (!nextParentSessionID) {
|
||||
rootSessionID = currentSessionID
|
||||
break
|
||||
}
|
||||
|
||||
currentSessionID = nextParentSessionID
|
||||
parentDepth += 1
|
||||
}
|
||||
|
||||
return {
|
||||
rootSessionID,
|
||||
parentDepth,
|
||||
childDepth: parentDepth + 1,
|
||||
}
|
||||
}
|
||||
|
||||
export function createSubagentDepthLimitError(input: {
|
||||
childDepth: number
|
||||
maxDepth: number
|
||||
parentSessionID: string
|
||||
rootSessionID: string
|
||||
}): Error {
|
||||
const { childDepth, maxDepth, parentSessionID, rootSessionID } = input
|
||||
return new Error(
|
||||
`Subagent spawn blocked: child depth ${childDepth} exceeds background_task.maxDepth=${maxDepth}. Parent session: ${parentSessionID}. Root session: ${rootSessionID}. Continue in an existing subagent session instead of spawning another.`
|
||||
)
|
||||
}
|
||||
|
||||
export function createSubagentDescendantLimitError(input: {
|
||||
rootSessionID: string
|
||||
descendantCount: number
|
||||
maxDescendants: number
|
||||
}): Error {
|
||||
const { rootSessionID, descendantCount, maxDescendants } = input
|
||||
return new Error(
|
||||
`Subagent spawn blocked: root session ${rootSessionID} already has ${descendantCount} descendants, which meets background_task.maxDescendants=${maxDescendants}. Reuse an existing session instead of spawning another.`
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, mock } from "bun:test"
|
||||
declare const require: (name: string) => any
|
||||
const { describe, it, expect, mock } = require("bun:test")
|
||||
|
||||
import { checkAndInterruptStaleTasks, pruneStaleTasksAndNotifications } from "./task-poller"
|
||||
import type { BackgroundTask } from "./types"
|
||||
@@ -419,6 +420,21 @@ describe("checkAndInterruptStaleTasks", () => {
|
||||
})
|
||||
|
||||
describe("pruneStaleTasksAndNotifications", () => {
|
||||
function createTerminalTask(overrides: Partial<BackgroundTask> = {}): BackgroundTask {
|
||||
return {
|
||||
id: "terminal-task",
|
||||
parentSessionID: "parent",
|
||||
parentMessageID: "msg",
|
||||
description: "terminal",
|
||||
prompt: "terminal",
|
||||
agent: "explore",
|
||||
status: "completed",
|
||||
startedAt: new Date(Date.now() - 40 * 60 * 1000),
|
||||
completedAt: new Date(Date.now() - 31 * 60 * 1000),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
it("should prune tasks that exceeded TTL", () => {
|
||||
//#given
|
||||
const tasks = new Map<string, BackgroundTask>()
|
||||
@@ -447,4 +463,52 @@ describe("pruneStaleTasksAndNotifications", () => {
|
||||
//#then
|
||||
expect(pruned).toContain("old-task")
|
||||
})
|
||||
|
||||
it("should prune terminal tasks when completion time exceeds terminal TTL", () => {
|
||||
//#given
|
||||
const tasks = new Map<string, BackgroundTask>()
|
||||
const terminalStatuses: BackgroundTask["status"][] = ["completed", "error", "cancelled", "interrupt"]
|
||||
|
||||
for (const status of terminalStatuses) {
|
||||
tasks.set(status, createTerminalTask({
|
||||
id: status,
|
||||
description: status,
|
||||
prompt: status,
|
||||
status,
|
||||
}))
|
||||
}
|
||||
|
||||
const pruned: string[] = []
|
||||
|
||||
//#when
|
||||
pruneStaleTasksAndNotifications({
|
||||
tasks,
|
||||
notifications: new Map<string, BackgroundTask[]>(),
|
||||
onTaskPruned: (taskId) => pruned.push(taskId),
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(pruned).toEqual([])
|
||||
expect(Array.from(tasks.keys())).toEqual([])
|
||||
})
|
||||
|
||||
it("should keep terminal tasks with pending notifications until notification cleanup", () => {
|
||||
//#given
|
||||
const task = createTerminalTask()
|
||||
const tasks = new Map<string, BackgroundTask>([[task.id, task]])
|
||||
const notifications = new Map<string, BackgroundTask[]>([[task.parentSessionID, [task]]])
|
||||
const pruned: string[] = []
|
||||
|
||||
//#when
|
||||
pruneStaleTasksAndNotifications({
|
||||
tasks,
|
||||
notifications,
|
||||
onTaskPruned: (taskId) => pruned.push(taskId),
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(pruned).toEqual([])
|
||||
expect(tasks.has(task.id)).toBe(true)
|
||||
expect(notifications.has(task.parentSessionID)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,15 @@ import {
|
||||
} from "./constants"
|
||||
import { removeTaskToastTracking } from "./remove-task-toast-tracking"
|
||||
|
||||
const TERMINAL_TASK_TTL_MS = 30 * 60 * 1000
|
||||
|
||||
const TERMINAL_TASK_STATUSES = new Set<BackgroundTask["status"]>([
|
||||
"completed",
|
||||
"error",
|
||||
"cancelled",
|
||||
"interrupt",
|
||||
])
|
||||
|
||||
export function pruneStaleTasksAndNotifications(args: {
|
||||
tasks: Map<string, BackgroundTask>
|
||||
notifications: Map<string, BackgroundTask[]>
|
||||
@@ -20,8 +29,29 @@ export function pruneStaleTasksAndNotifications(args: {
|
||||
}): void {
|
||||
const { tasks, notifications, onTaskPruned } = args
|
||||
const now = Date.now()
|
||||
const tasksWithPendingNotifications = new Set<string>()
|
||||
|
||||
for (const queued of notifications.values()) {
|
||||
for (const task of queued) {
|
||||
tasksWithPendingNotifications.add(task.id)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [taskId, task] of tasks.entries()) {
|
||||
if (TERMINAL_TASK_STATUSES.has(task.status)) {
|
||||
if (tasksWithPendingNotifications.has(taskId)) continue
|
||||
|
||||
const completedAt = task.completedAt?.getTime()
|
||||
if (!completedAt) continue
|
||||
|
||||
const age = now - completedAt
|
||||
if (age <= TERMINAL_TASK_TTL_MS) continue
|
||||
|
||||
removeTaskToastTracking(taskId)
|
||||
tasks.delete(taskId)
|
||||
continue
|
||||
}
|
||||
|
||||
const timestamp = task.status === "pending"
|
||||
? task.queuedAt?.getTime()
|
||||
: task.startedAt?.getTime()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { FallbackEntry } from "../../shared/model-requirements"
|
||||
import type { SessionPermissionRule } from "../../shared/question-denied-session-permission"
|
||||
|
||||
export type BackgroundTaskStatus =
|
||||
| "pending"
|
||||
@@ -19,11 +20,13 @@ export interface TaskProgress {
|
||||
export interface BackgroundTask {
|
||||
id: string
|
||||
sessionID?: string
|
||||
rootSessionID?: string
|
||||
parentSessionID: string
|
||||
parentMessageID: string
|
||||
description: string
|
||||
prompt: string
|
||||
agent: string
|
||||
spawnDepth?: number
|
||||
status: BackgroundTaskStatus
|
||||
queuedAt?: Date
|
||||
startedAt?: Date
|
||||
@@ -72,6 +75,7 @@ export interface LaunchInput {
|
||||
skills?: string[]
|
||||
skillContent?: string
|
||||
category?: string
|
||||
sessionPermission?: SessionPermissionRule[]
|
||||
}
|
||||
|
||||
export interface ResumeInput {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { mapClaudeModelToOpenCode } from "./claude-model-mapper"
|
||||
|
||||
describe("mapClaudeModelToOpenCode", () => {
|
||||
describe("#given undefined or empty input", () => {
|
||||
it("#when called with undefined #then returns undefined", () => {
|
||||
expect(mapClaudeModelToOpenCode(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it("#when called with empty string #then returns undefined", () => {
|
||||
expect(mapClaudeModelToOpenCode("")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("#when called with whitespace-only string #then returns undefined", () => {
|
||||
expect(mapClaudeModelToOpenCode(" ")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given Claude Code alias", () => {
|
||||
it("#when called with sonnet #then maps to anthropic claude-sonnet-4-6 object", () => {
|
||||
expect(mapClaudeModelToOpenCode("sonnet")).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-6" })
|
||||
})
|
||||
|
||||
it("#when called with opus #then maps to anthropic claude-opus-4-6 object", () => {
|
||||
expect(mapClaudeModelToOpenCode("opus")).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-6" })
|
||||
})
|
||||
|
||||
it("#when called with haiku #then maps to anthropic claude-haiku-4-5 object", () => {
|
||||
expect(mapClaudeModelToOpenCode("haiku")).toEqual({ providerID: "anthropic", modelID: "claude-haiku-4-5" })
|
||||
})
|
||||
|
||||
it("#when called with Sonnet (capitalized) #then maps case-insensitively to object", () => {
|
||||
expect(mapClaudeModelToOpenCode("Sonnet")).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-6" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given inherit", () => {
|
||||
it("#when called with inherit #then returns undefined", () => {
|
||||
expect(mapClaudeModelToOpenCode("inherit")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given bare Claude model name", () => {
|
||||
it("#when called with claude-sonnet-4-5-20250514 #then adds anthropic object format", () => {
|
||||
expect(mapClaudeModelToOpenCode("claude-sonnet-4-5-20250514")).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-5-20250514" })
|
||||
})
|
||||
|
||||
it("#when called with claude-opus-4-6 #then adds anthropic object format", () => {
|
||||
expect(mapClaudeModelToOpenCode("claude-opus-4-6")).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-6" })
|
||||
})
|
||||
|
||||
it("#when called with claude-haiku-4-5-20251001 #then adds anthropic object format", () => {
|
||||
expect(mapClaudeModelToOpenCode("claude-haiku-4-5-20251001")).toEqual({ providerID: "anthropic", modelID: "claude-haiku-4-5-20251001" })
|
||||
})
|
||||
|
||||
it("#when called with claude-3-5-sonnet-20241022 #then adds anthropic object format", () => {
|
||||
expect(mapClaudeModelToOpenCode("claude-3-5-sonnet-20241022")).toEqual({ providerID: "anthropic", modelID: "claude-3-5-sonnet-20241022" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given model with dot version numbers", () => {
|
||||
it("#when called with claude-3.5-sonnet #then normalizes dots and returns object format", () => {
|
||||
expect(mapClaudeModelToOpenCode("claude-3.5-sonnet")).toEqual({ providerID: "anthropic", modelID: "claude-3-5-sonnet" })
|
||||
})
|
||||
|
||||
it("#when called with claude-3.5-sonnet-20241022 #then normalizes dots and returns object format", () => {
|
||||
expect(mapClaudeModelToOpenCode("claude-3.5-sonnet-20241022")).toEqual({ providerID: "anthropic", modelID: "claude-3-5-sonnet-20241022" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given model already in provider/model format", () => {
|
||||
it("#when called with anthropic/claude-sonnet-4-6 #then splits into object format", () => {
|
||||
expect(mapClaudeModelToOpenCode("anthropic/claude-sonnet-4-6")).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-6" })
|
||||
})
|
||||
|
||||
it("#when called with openai/gpt-5.2 #then splits into object format", () => {
|
||||
expect(mapClaudeModelToOpenCode("openai/gpt-5.2")).toEqual({ providerID: "openai", modelID: "gpt-5.2" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given non-Claude bare model", () => {
|
||||
it("#when called with gpt-5.2 #then returns undefined", () => {
|
||||
expect(mapClaudeModelToOpenCode("gpt-5.2")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("#when called with gemini-3-flash #then returns undefined", () => {
|
||||
expect(mapClaudeModelToOpenCode("gemini-3-flash")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given prototype property name", () => {
|
||||
it("#when called with constructor #then returns undefined", () => {
|
||||
expect(mapClaudeModelToOpenCode("constructor")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("#when called with toString #then returns undefined", () => {
|
||||
expect(mapClaudeModelToOpenCode("toString")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given model with leading/trailing whitespace", () => {
|
||||
it("#when called with padded string #then trims before returning object format", () => {
|
||||
expect(mapClaudeModelToOpenCode(" claude-sonnet-4-6 ")).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-6" })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { normalizeModelFormat } from "../../shared/model-format-normalizer"
|
||||
import { normalizeModelID } from "../../shared/model-normalization"
|
||||
|
||||
const ANTHROPIC_PREFIX = "anthropic/"
|
||||
|
||||
const CLAUDE_CODE_ALIAS_MAP = new Map<string, string>([
|
||||
["sonnet", `${ANTHROPIC_PREFIX}claude-sonnet-4-6`],
|
||||
["opus", `${ANTHROPIC_PREFIX}claude-opus-4-6`],
|
||||
["haiku", `${ANTHROPIC_PREFIX}claude-haiku-4-5`],
|
||||
])
|
||||
|
||||
function mapClaudeModelString(model: string | undefined): string | undefined {
|
||||
if (!model) return undefined
|
||||
|
||||
const trimmed = model.trim()
|
||||
if (trimmed.length === 0) return undefined
|
||||
|
||||
if (trimmed === "inherit") return undefined
|
||||
|
||||
const aliasResult = CLAUDE_CODE_ALIAS_MAP.get(trimmed.toLowerCase())
|
||||
if (aliasResult) return aliasResult
|
||||
|
||||
if (trimmed.includes("/")) return trimmed
|
||||
|
||||
const normalized = normalizeModelID(trimmed)
|
||||
|
||||
if (normalized.startsWith("claude-")) {
|
||||
return `${ANTHROPIC_PREFIX}${normalized}`
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function mapClaudeModelToOpenCode(
|
||||
model: string | undefined
|
||||
): { providerID: string; modelID: string } | undefined {
|
||||
const mappedModel = mapClaudeModelString(model)
|
||||
return mappedModel ? normalizeModelFormat(mappedModel) : undefined
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "fs"
|
||||
import { join, basename } from "path"
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import { isMarkdownFile } from "../../shared/file-utils"
|
||||
import { getClaudeConfigDir } from "../../shared"
|
||||
import type { AgentScope, AgentFrontmatter, LoadedAgent } from "./types"
|
||||
import type { AgentScope, AgentFrontmatter, ClaudeCodeAgentConfig, LoadedAgent } from "./types"
|
||||
import { mapClaudeModelToOpenCode } from "./claude-model-mapper"
|
||||
|
||||
function parseToolsConfig(toolsStr?: string): Record<string, boolean> | undefined {
|
||||
if (!toolsStr) return undefined
|
||||
@@ -42,10 +42,13 @@ function loadAgentsFromDir(agentsDir: string, scope: AgentScope): LoadedAgent[]
|
||||
|
||||
const formattedDescription = `(${scope}) ${originalDescription}`
|
||||
|
||||
const config: AgentConfig = {
|
||||
const mappedModelOverride = mapClaudeModelToOpenCode(data.model)
|
||||
|
||||
const config: ClaudeCodeAgentConfig = {
|
||||
description: formattedDescription,
|
||||
mode: data.mode || "subagent",
|
||||
prompt: body.trim(),
|
||||
...(mappedModelOverride ? { model: mappedModelOverride } : {}),
|
||||
}
|
||||
|
||||
const toolsConfig = parseToolsConfig(data.tools)
|
||||
@@ -67,22 +70,22 @@ function loadAgentsFromDir(agentsDir: string, scope: AgentScope): LoadedAgent[]
|
||||
return agents
|
||||
}
|
||||
|
||||
export function loadUserAgents(): Record<string, AgentConfig> {
|
||||
export function loadUserAgents(): Record<string, ClaudeCodeAgentConfig> {
|
||||
const userAgentsDir = join(getClaudeConfigDir(), "agents")
|
||||
const agents = loadAgentsFromDir(userAgentsDir, "user")
|
||||
|
||||
const result: Record<string, AgentConfig> = {}
|
||||
const result: Record<string, ClaudeCodeAgentConfig> = {}
|
||||
for (const agent of agents) {
|
||||
result[agent.name] = agent.config
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function loadProjectAgents(directory?: string): Record<string, AgentConfig> {
|
||||
export function loadProjectAgents(directory?: string): Record<string, ClaudeCodeAgentConfig> {
|
||||
const projectAgentsDir = join(directory ?? process.cwd(), ".claude", "agents")
|
||||
const agents = loadAgentsFromDir(projectAgentsDir, "project")
|
||||
|
||||
const result: Record<string, AgentConfig> = {}
|
||||
const result: Record<string, ClaudeCodeAgentConfig> = {}
|
||||
for (const agent of agents) {
|
||||
result[agent.name] = agent.config
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@ import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
|
||||
export type AgentScope = "user" | "project"
|
||||
|
||||
export type ClaudeCodeAgentConfig = Omit<AgentConfig, "model"> & {
|
||||
model?: string | { providerID: string; modelID: string }
|
||||
}
|
||||
|
||||
export interface AgentFrontmatter {
|
||||
name?: string
|
||||
description?: string
|
||||
@@ -13,6 +17,6 @@ export interface AgentFrontmatter {
|
||||
export interface LoadedAgent {
|
||||
name: string
|
||||
path: string
|
||||
config: AgentConfig
|
||||
config: ClaudeCodeAgentConfig
|
||||
scope: AgentScope
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "fs"
|
||||
import { basename, join } from "path"
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import { isMarkdownFile } from "../../shared/file-utils"
|
||||
import { log } from "../../shared/logger"
|
||||
import type { AgentFrontmatter } from "../claude-code-agent-loader/types"
|
||||
import type { AgentFrontmatter, ClaudeCodeAgentConfig } from "../claude-code-agent-loader/types"
|
||||
import { mapClaudeModelToOpenCode } from "../claude-code-agent-loader/claude-model-mapper"
|
||||
import type { LoadedPlugin } from "./types"
|
||||
|
||||
function parseToolsConfig(toolsStr?: string): Record<string, boolean> | undefined {
|
||||
@@ -24,8 +24,8 @@ function parseToolsConfig(toolsStr?: string): Record<string, boolean> | undefine
|
||||
return result
|
||||
}
|
||||
|
||||
export function loadPluginAgents(plugins: LoadedPlugin[]): Record<string, AgentConfig> {
|
||||
const agents: Record<string, AgentConfig> = {}
|
||||
export function loadPluginAgents(plugins: LoadedPlugin[]): Record<string, ClaudeCodeAgentConfig> {
|
||||
const agents: Record<string, ClaudeCodeAgentConfig> = {}
|
||||
|
||||
for (const plugin of plugins) {
|
||||
if (!plugin.agentsDir || !existsSync(plugin.agentsDir)) continue
|
||||
@@ -46,10 +46,13 @@ export function loadPluginAgents(plugins: LoadedPlugin[]): Record<string, AgentC
|
||||
const originalDescription = data.description || ""
|
||||
const formattedDescription = `(plugin: ${plugin.name}) ${originalDescription}`
|
||||
|
||||
const config: AgentConfig = {
|
||||
const mappedModelOverride = mapClaudeModelToOpenCode(data.model)
|
||||
|
||||
const config: ClaudeCodeAgentConfig = {
|
||||
description: formattedDescription,
|
||||
mode: "subagent",
|
||||
prompt: body.trim(),
|
||||
...(mappedModelOverride ? { model: mappedModelOverride } : {}),
|
||||
}
|
||||
|
||||
const toolsConfig = parseToolsConfig(data.tools)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { log } from "../../shared/logger"
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { CommandDefinition } from "../claude-code-command-loader/types"
|
||||
import type { McpServerConfig } from "../claude-code-mcp-loader/types"
|
||||
import type { ClaudeCodeAgentConfig } from "../claude-code-agent-loader/types"
|
||||
import type { HooksConfig, LoadedPlugin, PluginLoadError, PluginLoaderOptions } from "./types"
|
||||
import { discoverInstalledPlugins } from "./discovery"
|
||||
import { loadPluginCommands } from "./command-loader"
|
||||
@@ -20,7 +20,7 @@ export { loadPluginHooksConfigs } from "./hook-loader"
|
||||
export interface PluginComponentsResult {
|
||||
commands: Record<string, CommandDefinition>
|
||||
skills: Record<string, CommandDefinition>
|
||||
agents: Record<string, AgentConfig>
|
||||
agents: Record<string, ClaudeCodeAgentConfig>
|
||||
mcpServers: Record<string, McpServerConfig>
|
||||
hooksConfigs: HooksConfig[]
|
||||
plugins: LoadedPlugin[]
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { injectGitMasterConfig } from "./git-master-template-injection"
|
||||
|
||||
const SAMPLE_TEMPLATE = [
|
||||
"# Git Master Agent",
|
||||
"",
|
||||
"## MODE DETECTION (FIRST STEP)",
|
||||
"",
|
||||
"Analyze the request.",
|
||||
"",
|
||||
"```bash",
|
||||
"git status",
|
||||
"git merge-base HEAD main 2>/dev/null || git merge-base HEAD master 2>/dev/null",
|
||||
"MERGE_BASE=$(git merge-base HEAD main)",
|
||||
"GIT_SEQUENCE_EDITOR=: git rebase -i --autosquash $MERGE_BASE",
|
||||
"```",
|
||||
"",
|
||||
"```",
|
||||
"</execution>",
|
||||
].join("\n")
|
||||
|
||||
describe("#given git_env_prefix config", () => {
|
||||
describe("#when default config (GIT_MASTER=1)", () => {
|
||||
it("#then injects env prefix section before MODE DETECTION", () => {
|
||||
const result = injectGitMasterConfig(SAMPLE_TEMPLATE, {
|
||||
commit_footer: false,
|
||||
include_co_authored_by: false,
|
||||
git_env_prefix: "GIT_MASTER=1",
|
||||
})
|
||||
|
||||
expect(result).toContain("## GIT COMMAND PREFIX (MANDATORY)")
|
||||
expect(result).toContain("GIT_MASTER=1 git status")
|
||||
expect(result).toContain("GIT_MASTER=1 git commit")
|
||||
expect(result).toContain("GIT_MASTER=1 git push")
|
||||
expect(result).toContain("EVERY git command MUST be prefixed with `GIT_MASTER=1`")
|
||||
|
||||
const prefixIndex = result.indexOf("## GIT COMMAND PREFIX")
|
||||
const modeIndex = result.indexOf("## MODE DETECTION")
|
||||
expect(prefixIndex).toBeLessThan(modeIndex)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when git_env_prefix is empty string", () => {
|
||||
it("#then does NOT inject env prefix section", () => {
|
||||
const result = injectGitMasterConfig(SAMPLE_TEMPLATE, {
|
||||
commit_footer: false,
|
||||
include_co_authored_by: false,
|
||||
git_env_prefix: "",
|
||||
})
|
||||
|
||||
expect(result).not.toContain("## GIT COMMAND PREFIX")
|
||||
expect(result).not.toContain("GIT_MASTER=1")
|
||||
expect(result).not.toContain("git_env_prefix")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when git_env_prefix is custom value", () => {
|
||||
it("#then injects custom prefix in section", () => {
|
||||
const result = injectGitMasterConfig(SAMPLE_TEMPLATE, {
|
||||
commit_footer: false,
|
||||
include_co_authored_by: false,
|
||||
git_env_prefix: "MY_HOOK=active",
|
||||
})
|
||||
|
||||
expect(result).toContain("MY_HOOK=active git status")
|
||||
expect(result).toContain("MY_HOOK=active git commit")
|
||||
expect(result).not.toContain("GIT_MASTER=1")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when git_env_prefix contains shell metacharacters", () => {
|
||||
it("#then rejects the malicious value", () => {
|
||||
expect(() =>
|
||||
injectGitMasterConfig(SAMPLE_TEMPLATE, {
|
||||
commit_footer: false,
|
||||
include_co_authored_by: false,
|
||||
git_env_prefix: "A=1; rm -rf /",
|
||||
})
|
||||
).toThrow('git_env_prefix must be empty or use shell-safe env assignments like "GIT_MASTER=1"')
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when no config provided", () => {
|
||||
it("#then uses default GIT_MASTER=1 prefix", () => {
|
||||
const result = injectGitMasterConfig(SAMPLE_TEMPLATE)
|
||||
|
||||
expect(result).toContain("GIT_MASTER=1 git status")
|
||||
expect(result).toContain("## GIT COMMAND PREFIX (MANDATORY)")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given git_env_prefix with commit footer", () => {
|
||||
describe("#when both env prefix and footer are enabled", () => {
|
||||
it("#then commit examples include the env prefix", () => {
|
||||
const result = injectGitMasterConfig(SAMPLE_TEMPLATE, {
|
||||
commit_footer: true,
|
||||
include_co_authored_by: false,
|
||||
git_env_prefix: "GIT_MASTER=1",
|
||||
})
|
||||
|
||||
expect(result).toContain("GIT_MASTER=1 git commit")
|
||||
expect(result).toContain("Ultraworked with [Sisyphus]")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when the template already contains bare git commands in bash blocks", () => {
|
||||
it("#then prefixes every git invocation in the final output", () => {
|
||||
const result = injectGitMasterConfig(SAMPLE_TEMPLATE, {
|
||||
commit_footer: false,
|
||||
include_co_authored_by: false,
|
||||
git_env_prefix: "GIT_MASTER=1",
|
||||
})
|
||||
|
||||
expect(result).toContain("GIT_MASTER=1 git status")
|
||||
expect(result).toContain(
|
||||
"GIT_MASTER=1 git merge-base HEAD main 2>/dev/null || GIT_MASTER=1 git merge-base HEAD master 2>/dev/null"
|
||||
)
|
||||
expect(result).toContain("MERGE_BASE=$(GIT_MASTER=1 git merge-base HEAD main)")
|
||||
expect(result).toContain(
|
||||
"GIT_SEQUENCE_EDITOR=: GIT_MASTER=1 git rebase -i --autosquash $MERGE_BASE"
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when env prefix disabled but footer enabled", () => {
|
||||
it("#then commit examples have no env prefix", () => {
|
||||
const result = injectGitMasterConfig(SAMPLE_TEMPLATE, {
|
||||
commit_footer: true,
|
||||
include_co_authored_by: false,
|
||||
git_env_prefix: "",
|
||||
})
|
||||
|
||||
expect(result).not.toContain("GIT_MASTER=1 git commit")
|
||||
expect(result).toContain("git commit -m")
|
||||
expect(result).toContain("Ultraworked with [Sisyphus]")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when both env prefix and co-author are enabled", () => {
|
||||
it("#then commit example includes prefix, footer, and co-author", () => {
|
||||
const result = injectGitMasterConfig(SAMPLE_TEMPLATE, {
|
||||
commit_footer: true,
|
||||
include_co_authored_by: true,
|
||||
git_env_prefix: "GIT_MASTER=1",
|
||||
})
|
||||
|
||||
expect(result).toContain("GIT_MASTER=1 git commit")
|
||||
expect(result).toContain("Ultraworked with [Sisyphus]")
|
||||
expect(result).toContain("Co-authored-by: Sisyphus")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,14 +1,88 @@
|
||||
import type { GitMasterConfig } from "../../config/schema"
|
||||
import { assertValidGitEnvPrefix, type GitMasterConfig } from "../../config/schema"
|
||||
|
||||
const BASH_CODE_BLOCK_PATTERN = /```bash\r?\n([\s\S]*?)```/g
|
||||
const LEADING_GIT_COMMAND_PATTERN = /^([ \t]*(?:[A-Za-z_][A-Za-z0-9_]*=[^ \t]+\s+)*)git(?=[ \t]|$)/gm
|
||||
const INLINE_GIT_COMMAND_PATTERN = /([;&|()][ \t]*)git(?=[ \t]|$)/g
|
||||
|
||||
export function injectGitMasterConfig(template: string, config?: GitMasterConfig): string {
|
||||
const commitFooter = config?.commit_footer ?? true
|
||||
const includeCoAuthoredBy = config?.include_co_authored_by ?? true
|
||||
const gitEnvPrefix = assertValidGitEnvPrefix(config?.git_env_prefix ?? "GIT_MASTER=1")
|
||||
|
||||
if (!commitFooter && !includeCoAuthoredBy) {
|
||||
return template
|
||||
let result = gitEnvPrefix ? injectGitEnvPrefix(template, gitEnvPrefix) : template
|
||||
|
||||
if (commitFooter || includeCoAuthoredBy) {
|
||||
const injection = buildCommitFooterInjection(commitFooter, includeCoAuthoredBy, gitEnvPrefix)
|
||||
const insertionPoint = result.indexOf("```\n</execution>")
|
||||
|
||||
result =
|
||||
insertionPoint !== -1
|
||||
? result.slice(0, insertionPoint) +
|
||||
"```\n\n" +
|
||||
injection +
|
||||
"\n</execution>" +
|
||||
result.slice(insertionPoint + "```\n</execution>".length)
|
||||
: result + "\n\n" + injection
|
||||
}
|
||||
|
||||
return gitEnvPrefix ? prefixGitCommandsInBashCodeBlocks(result, gitEnvPrefix) : result
|
||||
}
|
||||
|
||||
function injectGitEnvPrefix(template: string, prefix: string): string {
|
||||
const envPrefixSection = [
|
||||
"## GIT COMMAND PREFIX (MANDATORY)",
|
||||
"",
|
||||
`<git_env_prefix>`,
|
||||
`**EVERY git command MUST be prefixed with \`${prefix}\`.**`,
|
||||
"",
|
||||
"This allows custom git hooks to detect when git-master skill is active.",
|
||||
"",
|
||||
"```bash",
|
||||
`${prefix} git status`,
|
||||
`${prefix} git add <files>`,
|
||||
`${prefix} git commit -m "message"`,
|
||||
`${prefix} git push`,
|
||||
`${prefix} git rebase ...`,
|
||||
`${prefix} git log ...`,
|
||||
"```",
|
||||
"",
|
||||
"**NO EXCEPTIONS. Every `git` invocation must include this prefix.**",
|
||||
`</git_env_prefix>`,
|
||||
].join("\n")
|
||||
|
||||
const modeDetectionMarker = "## MODE DETECTION (FIRST STEP)"
|
||||
const markerIndex = template.indexOf(modeDetectionMarker)
|
||||
if (markerIndex !== -1) {
|
||||
return (
|
||||
template.slice(0, markerIndex) +
|
||||
envPrefixSection +
|
||||
"\n\n---\n\n" +
|
||||
template.slice(markerIndex)
|
||||
)
|
||||
}
|
||||
|
||||
return envPrefixSection + "\n\n---\n\n" + template
|
||||
}
|
||||
|
||||
function prefixGitCommandsInBashCodeBlocks(template: string, prefix: string): string {
|
||||
return template.replace(BASH_CODE_BLOCK_PATTERN, (block, codeBlock: string) => {
|
||||
return block.replace(codeBlock, prefixGitCommandsInCodeBlock(codeBlock, prefix))
|
||||
})
|
||||
}
|
||||
|
||||
function prefixGitCommandsInCodeBlock(codeBlock: string, prefix: string): string {
|
||||
return codeBlock
|
||||
.replace(LEADING_GIT_COMMAND_PATTERN, `$1${prefix} git`)
|
||||
.replace(INLINE_GIT_COMMAND_PATTERN, `$1${prefix} git`)
|
||||
}
|
||||
|
||||
function buildCommitFooterInjection(
|
||||
commitFooter: boolean | string,
|
||||
includeCoAuthoredBy: boolean,
|
||||
gitEnvPrefix: string,
|
||||
): string {
|
||||
const sections: string[] = []
|
||||
const cmdPrefix = gitEnvPrefix ? `${gitEnvPrefix} ` : ""
|
||||
|
||||
sections.push("### 5.5 Commit Footer & Co-Author")
|
||||
sections.push("")
|
||||
@@ -43,7 +117,7 @@ export function injectGitMasterConfig(template: string, config?: GitMasterConfig
|
||||
sections.push("**Example (both enabled):**")
|
||||
sections.push("```bash")
|
||||
sections.push(
|
||||
`git commit -m "{Commit Message}" -m "${footerText}" -m "Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>"`
|
||||
`${cmdPrefix}git commit -m "{Commit Message}" -m "${footerText}" -m "Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>"`
|
||||
)
|
||||
sections.push("```")
|
||||
} else if (commitFooter) {
|
||||
@@ -53,29 +127,16 @@ export function injectGitMasterConfig(template: string, config?: GitMasterConfig
|
||||
: "Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)"
|
||||
sections.push("**Example:**")
|
||||
sections.push("```bash")
|
||||
sections.push(`git commit -m "{Commit Message}" -m "${footerText}"`)
|
||||
sections.push(`${cmdPrefix}git commit -m "{Commit Message}" -m "${footerText}"`)
|
||||
sections.push("```")
|
||||
} else if (includeCoAuthoredBy) {
|
||||
sections.push("**Example:**")
|
||||
sections.push("```bash")
|
||||
sections.push(
|
||||
"git commit -m \"{Commit Message}\" -m \"Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>\""
|
||||
`${cmdPrefix}git commit -m "{Commit Message}" -m "Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>"`
|
||||
)
|
||||
sections.push("```")
|
||||
}
|
||||
|
||||
const injection = sections.join("\n")
|
||||
|
||||
const insertionPoint = template.indexOf("```\n</execution>")
|
||||
if (insertionPoint !== -1) {
|
||||
return (
|
||||
template.slice(0, insertionPoint) +
|
||||
"```\n\n" +
|
||||
injection +
|
||||
"\n</execution>" +
|
||||
template.slice(insertionPoint + "```\n</execution>".length)
|
||||
)
|
||||
}
|
||||
|
||||
return template + "\n\n" + injection
|
||||
return sections.join("\n")
|
||||
}
|
||||
|
||||
@@ -228,6 +228,7 @@ describe("resolveMultipleSkillsAsync", () => {
|
||||
gitMasterConfig: {
|
||||
commit_footer: false,
|
||||
include_co_authored_by: false,
|
||||
git_env_prefix: "GIT_MASTER=1",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -249,6 +250,7 @@ describe("resolveMultipleSkillsAsync", () => {
|
||||
gitMasterConfig: {
|
||||
commit_footer: true,
|
||||
include_co_authored_by: true,
|
||||
git_env_prefix: "GIT_MASTER=1",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -269,6 +271,7 @@ describe("resolveMultipleSkillsAsync", () => {
|
||||
gitMasterConfig: {
|
||||
commit_footer: true,
|
||||
include_co_authored_by: false,
|
||||
git_env_prefix: "GIT_MASTER=1",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -302,6 +305,7 @@ describe("resolveMultipleSkillsAsync", () => {
|
||||
gitMasterConfig: {
|
||||
commit_footer: false,
|
||||
include_co_authored_by: true,
|
||||
git_env_prefix: "GIT_MASTER=1",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -322,6 +326,7 @@ describe("resolveMultipleSkillsAsync", () => {
|
||||
gitMasterConfig: {
|
||||
commit_footer: customFooter,
|
||||
include_co_authored_by: false,
|
||||
git_env_prefix: "GIT_MASTER=1",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -341,6 +346,7 @@ describe("resolveMultipleSkillsAsync", () => {
|
||||
gitMasterConfig: {
|
||||
commit_footer: true,
|
||||
include_co_authored_by: false,
|
||||
git_env_prefix: "GIT_MASTER=1",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ export * from "./session-status-parser"
|
||||
export * from "./session-message-count"
|
||||
export * from "./session-ready-waiter"
|
||||
export * from "./types"
|
||||
export * from "./pane-state-parser"
|
||||
export * from "./pane-state-querier"
|
||||
export * from "./decision-engine"
|
||||
export * from "./action-executor"
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/// <reference path="../../../bun-test.d.ts" />
|
||||
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { parsePaneStateOutput } from "./pane-state-parser"
|
||||
|
||||
describe("parsePaneStateOutput", () => {
|
||||
it("rejects malformed integer fields", () => {
|
||||
// given
|
||||
const stdout = "%0\t120oops\t40\t0\t0\t1\t120\t40\n"
|
||||
|
||||
// when
|
||||
const result = parsePaneStateOutput(stdout)
|
||||
|
||||
// then
|
||||
expect(result).toBe(null)
|
||||
})
|
||||
|
||||
it("rejects negative integer fields", () => {
|
||||
// given
|
||||
const stdout = "%0\t-1\t40\t0\t0\t1\t120\t40\n"
|
||||
|
||||
// when
|
||||
const result = parsePaneStateOutput(stdout)
|
||||
|
||||
// then
|
||||
expect(result).toBe(null)
|
||||
})
|
||||
|
||||
it("rejects empty integer fields", () => {
|
||||
// given
|
||||
const stdout = "%0\t\t40\t0\t0\t1\t120\t40\n"
|
||||
|
||||
// when
|
||||
const result = parsePaneStateOutput(stdout)
|
||||
|
||||
// then
|
||||
expect(result).toBe(null)
|
||||
})
|
||||
|
||||
it("rejects non-binary active flags", () => {
|
||||
// given
|
||||
const stdout = "%0\t120\t40\t0\t0\tx\t120\t40\n"
|
||||
|
||||
// when
|
||||
const result = parsePaneStateOutput(stdout)
|
||||
|
||||
// then
|
||||
expect(result).toBe(null)
|
||||
})
|
||||
|
||||
it("rejects numeric active flags other than zero or one", () => {
|
||||
// given
|
||||
const stdout = "%0\t120\t40\t0\t0\t2\t120\t40\n"
|
||||
|
||||
// when
|
||||
const result = parsePaneStateOutput(stdout)
|
||||
|
||||
// then
|
||||
expect(result).toBe(null)
|
||||
})
|
||||
|
||||
it("rejects empty active flags", () => {
|
||||
// given
|
||||
const stdout = "%0\t120\t40\t0\t0\t\t120\t40\n"
|
||||
|
||||
// when
|
||||
const result = parsePaneStateOutput(stdout)
|
||||
|
||||
// then
|
||||
expect(result).toBe(null)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { TmuxPaneInfo } from "./types"
|
||||
|
||||
const MANDATORY_PANE_FIELD_COUNT = 8
|
||||
|
||||
type ParsedPaneState = {
|
||||
windowWidth: number
|
||||
windowHeight: number
|
||||
panes: TmuxPaneInfo[]
|
||||
}
|
||||
|
||||
type ParsedPaneLine = {
|
||||
pane: TmuxPaneInfo
|
||||
windowWidth: number
|
||||
windowHeight: number
|
||||
}
|
||||
|
||||
type MandatoryPaneFields = [
|
||||
paneId: string,
|
||||
widthString: string,
|
||||
heightString: string,
|
||||
leftString: string,
|
||||
topString: string,
|
||||
activeString: string,
|
||||
windowWidthString: string,
|
||||
windowHeightString: string,
|
||||
]
|
||||
|
||||
export function parsePaneStateOutput(stdout: string): ParsedPaneState | null {
|
||||
const lines = stdout
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/\r$/, ""))
|
||||
.filter((line) => line.length > 0)
|
||||
|
||||
if (lines.length === 0) return null
|
||||
|
||||
const parsedPaneLines = lines
|
||||
.map(parsePaneLine)
|
||||
.filter((parsedPaneLine): parsedPaneLine is ParsedPaneLine => parsedPaneLine !== null)
|
||||
|
||||
if (parsedPaneLines.length === 0) return null
|
||||
|
||||
const latestPaneLine = parsedPaneLines[parsedPaneLines.length - 1]
|
||||
if (!latestPaneLine) return null
|
||||
|
||||
return {
|
||||
windowWidth: latestPaneLine.windowWidth,
|
||||
windowHeight: latestPaneLine.windowHeight,
|
||||
panes: parsedPaneLines.map(({ pane }) => pane),
|
||||
}
|
||||
}
|
||||
|
||||
function parsePaneLine(line: string): ParsedPaneLine | null {
|
||||
const fields = line.split("\t")
|
||||
const mandatoryFields = getMandatoryPaneFields(fields)
|
||||
if (!mandatoryFields) return null
|
||||
|
||||
const [paneId, widthString, heightString, leftString, topString, activeString, windowWidthString, windowHeightString] = mandatoryFields
|
||||
|
||||
const width = parseInteger(widthString)
|
||||
const height = parseInteger(heightString)
|
||||
const left = parseInteger(leftString)
|
||||
const top = parseInteger(topString)
|
||||
const isActive = parseActiveValue(activeString)
|
||||
const windowWidth = parseInteger(windowWidthString)
|
||||
const windowHeight = parseInteger(windowHeightString)
|
||||
|
||||
if (
|
||||
width === null ||
|
||||
height === null ||
|
||||
left === null ||
|
||||
top === null ||
|
||||
isActive === null ||
|
||||
windowWidth === null ||
|
||||
windowHeight === null
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
pane: {
|
||||
paneId,
|
||||
width,
|
||||
height,
|
||||
left,
|
||||
top,
|
||||
title: fields.slice(MANDATORY_PANE_FIELD_COUNT).join("\t"),
|
||||
isActive,
|
||||
},
|
||||
windowWidth,
|
||||
windowHeight,
|
||||
}
|
||||
}
|
||||
|
||||
function getMandatoryPaneFields(fields: string[]): MandatoryPaneFields | null {
|
||||
if (fields.length < MANDATORY_PANE_FIELD_COUNT) return null
|
||||
|
||||
const [paneId, widthString, heightString, leftString, topString, activeString, windowWidthString, windowHeightString] = fields
|
||||
|
||||
if (
|
||||
paneId === undefined ||
|
||||
widthString === undefined ||
|
||||
heightString === undefined ||
|
||||
leftString === undefined ||
|
||||
topString === undefined ||
|
||||
activeString === undefined ||
|
||||
windowWidthString === undefined ||
|
||||
windowHeightString === undefined
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return [
|
||||
paneId,
|
||||
widthString,
|
||||
heightString,
|
||||
leftString,
|
||||
topString,
|
||||
activeString,
|
||||
windowWidthString,
|
||||
windowHeightString,
|
||||
]
|
||||
}
|
||||
|
||||
function parseInteger(value: string): number | null {
|
||||
if (!/^\d+$/.test(value)) return null
|
||||
|
||||
const parsedValue = Number.parseInt(value, 10)
|
||||
return Number.isNaN(parsedValue) ? null : parsedValue
|
||||
}
|
||||
|
||||
function parseActiveValue(value: string): boolean | null {
|
||||
if (value === "1") return true
|
||||
if (value === "0") return false
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/// <reference types="bun-types/test" />
|
||||
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { parsePaneStateOutput } from "./pane-state-parser"
|
||||
|
||||
describe("parsePaneStateOutput", () => {
|
||||
it("accepts a single pane when tmux omits the empty trailing title field", () => {
|
||||
// given
|
||||
const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\n"
|
||||
|
||||
// when
|
||||
const result = parsePaneStateOutput(stdout)
|
||||
|
||||
// then
|
||||
expect(result).not.toBe(null)
|
||||
expect(result).toEqual({
|
||||
windowWidth: 120,
|
||||
windowHeight: 40,
|
||||
panes: [
|
||||
{
|
||||
paneId: "%0",
|
||||
width: 120,
|
||||
height: 40,
|
||||
left: 0,
|
||||
top: 0,
|
||||
title: "",
|
||||
isActive: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("handles CRLF line endings without dropping panes", () => {
|
||||
// given
|
||||
const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\r\n%1\t60\t40\t60\t0\t0\t120\t40\tagent\r\n"
|
||||
|
||||
// when
|
||||
const result = parsePaneStateOutput(stdout)
|
||||
|
||||
// then
|
||||
expect(result).not.toBe(null)
|
||||
expect(result?.panes).toEqual([
|
||||
{
|
||||
paneId: "%0",
|
||||
width: 120,
|
||||
height: 40,
|
||||
left: 0,
|
||||
top: 0,
|
||||
title: "",
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
paneId: "%1",
|
||||
width: 60,
|
||||
height: 40,
|
||||
left: 60,
|
||||
top: 0,
|
||||
title: "agent",
|
||||
isActive: false,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("preserves tabs inside pane titles", () => {
|
||||
// given
|
||||
const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\ttitle\twith\ttabs\n"
|
||||
|
||||
// when
|
||||
const result = parsePaneStateOutput(stdout)
|
||||
|
||||
// then
|
||||
expect(result).not.toBe(null)
|
||||
expect(result?.panes[0]?.title).toBe("title\twith\ttabs")
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { spawn } from "bun"
|
||||
import type { WindowState, TmuxPaneInfo } from "./types"
|
||||
import { parsePaneStateOutput } from "./pane-state-parser"
|
||||
import { getTmuxPath } from "../../tools/interactive-bash/tmux-path-resolver"
|
||||
import { log } from "../../shared"
|
||||
|
||||
@@ -27,32 +28,18 @@ export async function queryWindowState(sourcePaneId: string): Promise<WindowStat
|
||||
return null
|
||||
}
|
||||
|
||||
const lines = stdout.trim().replace(/\r/g, "").split("\n").filter(Boolean)
|
||||
if (lines.length === 0) return null
|
||||
|
||||
let windowWidth = 0
|
||||
let windowHeight = 0
|
||||
const panes: TmuxPaneInfo[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
const fields = line.split("\t")
|
||||
if (fields.length < 8) continue
|
||||
|
||||
const [paneId, widthStr, heightStr, leftStr, topStr, activeStr, windowWidthStr, windowHeightStr] = fields
|
||||
const title = fields.length > 8 ? fields.slice(8).join("\t") : ""
|
||||
const width = parseInt(widthStr, 10)
|
||||
const height = parseInt(heightStr, 10)
|
||||
const left = parseInt(leftStr, 10)
|
||||
const top = parseInt(topStr, 10)
|
||||
const isActive = activeStr === "1"
|
||||
windowWidth = parseInt(windowWidthStr, 10)
|
||||
windowHeight = parseInt(windowHeightStr, 10)
|
||||
|
||||
if (!isNaN(width) && !isNaN(left) && !isNaN(height) && !isNaN(top)) {
|
||||
panes.push({ paneId, width, height, left, top, title, isActive })
|
||||
}
|
||||
const parsedPaneState = parsePaneStateOutput(stdout)
|
||||
if (!parsedPaneState) {
|
||||
log("[pane-state-querier] failed to parse pane state output", {
|
||||
sourcePaneId,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const { panes } = parsedPaneState
|
||||
const windowWidth = parsedPaneState.windowWidth
|
||||
const windowHeight = parsedPaneState.windowHeight
|
||||
|
||||
panes.sort((a, b) => a.left - b.left || a.top - b.top)
|
||||
|
||||
const mainPane = panes.reduce<TmuxPaneInfo | null>((selected, pane) => {
|
||||
|
||||
@@ -110,6 +110,7 @@ function scheduleRetry(input: {
|
||||
const currentProgress = getPlanProgress(currentBoulder.active_plan)
|
||||
if (currentProgress.isComplete) return
|
||||
if (options?.isContinuationStopped?.(sessionID)) return
|
||||
if (options?.shouldSkipContinuation?.(sessionID)) return
|
||||
if (hasRunningBackgroundTasks(sessionID, options)) return
|
||||
|
||||
await injectContinuation({
|
||||
@@ -192,6 +193,11 @@ export async function handleAtlasSessionIdle(input: {
|
||||
return
|
||||
}
|
||||
|
||||
if (options?.shouldSkipContinuation?.(sessionID)) {
|
||||
log(`[${HOOK_NAME}] Skipped: another continuation hook already injected`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (sessionState.lastContinuationInjectedAt && now - sessionState.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS) {
|
||||
scheduleRetry({ ctx, sessionID, sessionState, options })
|
||||
log(`[${HOOK_NAME}] Skipped: continuation cooldown active`, {
|
||||
|
||||
@@ -1042,6 +1042,37 @@ describe("atlas hook", () => {
|
||||
expect(mockInput._promptMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("should skip when another continuation hook already injected", async () => {
|
||||
// given - boulder state with incomplete plan
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
|
||||
|
||||
const state: BoulderState = {
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [MAIN_SESSION_ID],
|
||||
plan_name: "test-plan",
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput, {
|
||||
directory: TEST_DIR,
|
||||
shouldSkipContinuation: (sessionID: string) => sessionID === MAIN_SESSION_ID,
|
||||
})
|
||||
|
||||
// when
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: MAIN_SESSION_ID },
|
||||
},
|
||||
})
|
||||
|
||||
// then - should not call prompt because another continuation already handled it
|
||||
expect(mockInput._promptMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("should clear abort state on message.updated", async () => {
|
||||
// given - boulder with incomplete plan
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface AtlasHookOptions {
|
||||
directory: string
|
||||
backgroundManager?: BackgroundManager
|
||||
isContinuationStopped?: (sessionID: string) => boolean
|
||||
shouldSkipContinuation?: (sessionID: string) => boolean
|
||||
agentOverrides?: AgentOverrides
|
||||
/** Enable auto-commit after each atomic task completion (default: true) */
|
||||
autoCommit?: boolean
|
||||
|
||||
@@ -3,7 +3,7 @@ export { getLocalDevVersion } from "./checker/local-dev-version"
|
||||
export { findPluginEntry } from "./checker/plugin-entry"
|
||||
export type { PluginEntryInfo } from "./checker/plugin-entry"
|
||||
export { getCachedVersion } from "./checker/cached-version"
|
||||
export { updatePinnedVersion, revertPinnedVersion } from "./checker/pinned-version-updater"
|
||||
export { updatePinnedVersion } from "./checker/pinned-version-updater"
|
||||
export { getLatestVersion } from "./checker/latest-version"
|
||||
export { checkForUpdate } from "./checker/check-for-update"
|
||||
export { syncCachePackageJsonToIntent } from "./checker/sync-package-json"
|
||||
|
||||
@@ -10,11 +10,8 @@ interface CachePackageJson {
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
/** Whether the package.json was successfully synced/updated */
|
||||
synced: boolean
|
||||
/** Whether there was an error during sync (null if no error) */
|
||||
error: "file_not_found" | "plugin_not_in_deps" | "parse_error" | "write_error" | null
|
||||
/** Human-readable message describing what happened */
|
||||
message?: string
|
||||
}
|
||||
|
||||
@@ -28,35 +25,13 @@ function safeUnlink(filePath: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the version specifier to use in cache package.json based on opencode.json intent.
|
||||
*
|
||||
* - "oh-my-opencode" (no version) → "latest"
|
||||
* - "oh-my-opencode@latest" → "latest"
|
||||
* - "oh-my-opencode@next" → "next"
|
||||
* - "oh-my-opencode@3.10.0" → "3.10.0" (pinned, use as-is)
|
||||
*/
|
||||
function getIntentVersion(pluginInfo: PluginEntryInfo): string {
|
||||
if (!pluginInfo.pinnedVersion) {
|
||||
// No version specified in opencode.json, default to latest
|
||||
return "latest"
|
||||
}
|
||||
return pluginInfo.pinnedVersion
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the cache package.json to match the opencode.json plugin intent.
|
||||
*
|
||||
* OpenCode pins resolved versions in cache package.json (e.g., "3.11.0" instead of "latest").
|
||||
* This causes issues when users switch from pinned to tag in opencode.json:
|
||||
* - User changes opencode.json from "oh-my-opencode@3.10.0" to "oh-my-opencode@latest"
|
||||
* - Cache package.json still has "3.10.0"
|
||||
* - bun install reinstalls 3.10.0 instead of resolving @latest
|
||||
*
|
||||
* This function updates cache package.json to match the user's intent before bun install.
|
||||
*
|
||||
* @returns SyncResult with synced status and any error information
|
||||
*/
|
||||
export function syncCachePackageJsonToIntent(pluginInfo: PluginEntryInfo): SyncResult {
|
||||
const cachePackageJsonPath = path.join(CACHE_DIR, "package.json")
|
||||
|
||||
@@ -95,9 +70,6 @@ export function syncCachePackageJsonToIntent(pluginInfo: PluginEntryInfo): SyncR
|
||||
return { synced: false, error: null, message: `Already matches intent: ${intentVersion}` }
|
||||
}
|
||||
|
||||
// Check if this is a meaningful change:
|
||||
// - If intent is a tag (latest, next, beta) and current is semver, we need to update
|
||||
// - If both are semver but different, user explicitly changed versions
|
||||
const intentIsTag = !EXACT_SEMVER_REGEX.test(intentVersion.trim())
|
||||
const currentIsSemver = EXACT_SEMVER_REGEX.test(currentVersion.trim())
|
||||
|
||||
|
||||
@@ -54,6 +54,26 @@ function createPluginInput() {
|
||||
} as never
|
||||
}
|
||||
|
||||
async function flushScheduledWork(): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 0)
|
||||
})
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
function runSessionCreatedEvent(
|
||||
hook: ReturnType<HookFactory>,
|
||||
properties?: { info?: { parentID?: string } }
|
||||
): void {
|
||||
hook.event({
|
||||
event: {
|
||||
type: "session.created",
|
||||
properties,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockShowConfigErrorsIfAny.mockClear()
|
||||
mockShowModelCacheWarningIfNeeded.mockClear()
|
||||
@@ -85,13 +105,8 @@ describe("createAutoUpdateCheckerHook", () => {
|
||||
})
|
||||
|
||||
//#when - session.created event arrives
|
||||
hook.event({
|
||||
event: {
|
||||
type: "session.created",
|
||||
properties: { info: { parentID: undefined } },
|
||||
},
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
runSessionCreatedEvent(hook, { info: { parentID: undefined } })
|
||||
await flushScheduledWork()
|
||||
|
||||
//#then - no update checker side effects run
|
||||
expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled()
|
||||
@@ -108,12 +123,8 @@ describe("createAutoUpdateCheckerHook", () => {
|
||||
const hook = createAutoUpdateCheckerHook(createPluginInput())
|
||||
|
||||
//#when - session.created event arrives on primary session
|
||||
hook.event({
|
||||
event: {
|
||||
type: "session.created",
|
||||
},
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
runSessionCreatedEvent(hook)
|
||||
await flushScheduledWork()
|
||||
|
||||
//#then - startup checks, toast, and background check run
|
||||
expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1)
|
||||
@@ -129,13 +140,8 @@ describe("createAutoUpdateCheckerHook", () => {
|
||||
const hook = createAutoUpdateCheckerHook(createPluginInput())
|
||||
|
||||
//#when - session.created event contains parentID
|
||||
hook.event({
|
||||
event: {
|
||||
type: "session.created",
|
||||
properties: { info: { parentID: "parent-123" } },
|
||||
},
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
runSessionCreatedEvent(hook, { info: { parentID: "parent-123" } })
|
||||
await flushScheduledWork()
|
||||
|
||||
//#then - no startup actions run
|
||||
expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled()
|
||||
@@ -152,17 +158,9 @@ describe("createAutoUpdateCheckerHook", () => {
|
||||
const hook = createAutoUpdateCheckerHook(createPluginInput())
|
||||
|
||||
//#when - session.created event is fired twice
|
||||
hook.event({
|
||||
event: {
|
||||
type: "session.created",
|
||||
},
|
||||
})
|
||||
hook.event({
|
||||
event: {
|
||||
type: "session.created",
|
||||
},
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
runSessionCreatedEvent(hook)
|
||||
runSessionCreatedEvent(hook)
|
||||
await flushScheduledWork()
|
||||
|
||||
//#then - side effects execute only once
|
||||
expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1)
|
||||
@@ -179,12 +177,8 @@ describe("createAutoUpdateCheckerHook", () => {
|
||||
const hook = createAutoUpdateCheckerHook(createPluginInput())
|
||||
|
||||
//#when - session.created event arrives
|
||||
hook.event({
|
||||
event: {
|
||||
type: "session.created",
|
||||
},
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
runSessionCreatedEvent(hook)
|
||||
await flushScheduledWork()
|
||||
|
||||
//#then - local dev toast is shown and background check is skipped
|
||||
expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1)
|
||||
@@ -206,7 +200,7 @@ describe("createAutoUpdateCheckerHook", () => {
|
||||
type: "session.deleted",
|
||||
},
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
await flushScheduledWork()
|
||||
|
||||
//#then - no startup actions run
|
||||
expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled()
|
||||
@@ -225,12 +219,8 @@ describe("createAutoUpdateCheckerHook", () => {
|
||||
})
|
||||
|
||||
//#when - session.created event arrives
|
||||
hook.event({
|
||||
event: {
|
||||
type: "session.created",
|
||||
},
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
runSessionCreatedEvent(hook)
|
||||
await flushScheduledWork()
|
||||
|
||||
//#then - startup toast includes sisyphus wording
|
||||
expect(mockShowVersionToast).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
createSystemDirective,
|
||||
SystemDirectiveTypes,
|
||||
} from "../../shared/system-directive"
|
||||
|
||||
export const COMPACTION_CONTEXT_PROMPT = `${createSystemDirective(SystemDirectiveTypes.COMPACTION_CONTEXT)}
|
||||
|
||||
When summarizing this session, you MUST include the following sections in your summary:
|
||||
|
||||
## 1. User Requests (As-Is)
|
||||
- List all original user requests exactly as they were stated
|
||||
- Preserve the user's exact wording and intent
|
||||
|
||||
## 2. Final Goal
|
||||
- What the user ultimately wanted to achieve
|
||||
- The end result or deliverable expected
|
||||
|
||||
## 3. Work Completed
|
||||
- What has been done so far
|
||||
- Files created/modified
|
||||
- Features implemented
|
||||
- Problems solved
|
||||
|
||||
## 4. Remaining Tasks
|
||||
- What still needs to be done
|
||||
- Pending items from the original request
|
||||
- Follow-up tasks identified during the work
|
||||
|
||||
## 5. Active Working Context (For Seamless Continuation)
|
||||
- **Files**: Paths of files currently being edited or frequently referenced
|
||||
- **Code in Progress**: Key code snippets, function signatures, or data structures under active development
|
||||
- **External References**: Documentation URLs, library APIs, or external resources being consulted
|
||||
- **State & Variables**: Important variable names, configuration values, or runtime state relevant to ongoing work
|
||||
|
||||
## 6. Explicit Constraints (Verbatim Only)
|
||||
- Include ONLY constraints explicitly stated by the user or in existing AGENTS.md context
|
||||
- Quote constraints verbatim (do not paraphrase)
|
||||
- Do NOT invent, add, or modify constraints
|
||||
- If no explicit constraints exist, write "None"
|
||||
|
||||
## 7. Agent Verification State (Critical for Reviewers)
|
||||
- **Current Agent**: What agent is running (momus, oracle, etc.)
|
||||
- **Verification Progress**: Files already verified/validated
|
||||
- **Pending Verifications**: Files still needing verification
|
||||
- **Previous Rejections**: If reviewer agent, what was rejected and why
|
||||
- **Acceptance Status**: Current state of review process
|
||||
|
||||
This section is CRITICAL for reviewer agents (momus, oracle) to maintain continuity.
|
||||
|
||||
## 8. Delegated Agent Sessions
|
||||
- List ALL background agent tasks spawned during this session
|
||||
- For each: agent name, category, status, description, and **session_id**
|
||||
- **RESUME, DON'T RESTART.** Each listed session retains full context. After compaction, use \`session_id\` to continue existing agent sessions instead of spawning new ones. This saves tokens, preserves learned context, and prevents duplicate work.
|
||||
|
||||
This context is critical for maintaining continuity after compaction.
|
||||
`
|
||||
@@ -0,0 +1,5 @@
|
||||
export const HOOK_NAME = "compaction-context-injector"
|
||||
export const AGENT_RECOVERY_PROMPT = "[restore checkpointed session agent configuration after compaction]"
|
||||
export const NO_TEXT_TAIL_THRESHOLD = 5
|
||||
export const RECOVERY_COOLDOWN_MS = 60_000
|
||||
export const RECENT_COMPACTION_WINDOW_MS = 10 * 60 * 1000
|
||||
@@ -1,63 +1,60 @@
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import {
|
||||
createSystemDirective,
|
||||
SystemDirectiveTypes,
|
||||
} from "../../shared/system-directive"
|
||||
clearCompactionAgentConfigCheckpoint,
|
||||
setCompactionAgentConfigCheckpoint,
|
||||
} from "../../shared/compaction-agent-config-checkpoint"
|
||||
import { log } from "../../shared/logger"
|
||||
import { COMPACTION_CONTEXT_PROMPT } from "./compaction-context-prompt"
|
||||
import { resolveSessionPromptConfig } from "./session-prompt-config-resolver"
|
||||
import { finalizeTrackedAssistantMessage, shouldTreatAssistantPartAsOutput, trackAssistantOutput, type TailMonitorState } from "./tail-monitor"
|
||||
import { resolveSessionID } from "./session-id"
|
||||
import type { CompactionContextClient, CompactionContextInjector } from "./types"
|
||||
import { createRecoveryLogic } from "./recovery"
|
||||
|
||||
const COMPACTION_CONTEXT_PROMPT = `${createSystemDirective(SystemDirectiveTypes.COMPACTION_CONTEXT)}
|
||||
export function createCompactionContextInjector(options?: {
|
||||
ctx?: CompactionContextClient
|
||||
backgroundManager?: BackgroundManager
|
||||
}): CompactionContextInjector {
|
||||
const ctx = options?.ctx
|
||||
const backgroundManager = options?.backgroundManager
|
||||
const tailStates = new Map<string, TailMonitorState>()
|
||||
|
||||
When summarizing this session, you MUST include the following sections in your summary:
|
||||
const getTailState = (sessionID: string): TailMonitorState => {
|
||||
const existing = tailStates.get(sessionID)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
## 1. User Requests (As-Is)
|
||||
- List all original user requests exactly as they were stated
|
||||
- Preserve the user's exact wording and intent
|
||||
const created: TailMonitorState = {
|
||||
currentHasOutput: false,
|
||||
consecutiveNoTextMessages: 0,
|
||||
}
|
||||
tailStates.set(sessionID, created)
|
||||
return created
|
||||
}
|
||||
|
||||
## 2. Final Goal
|
||||
- What the user ultimately wanted to achieve
|
||||
- The end result or deliverable expected
|
||||
const { recoverCheckpointedAgentConfig, maybeWarnAboutNoTextTail } = createRecoveryLogic(ctx, getTailState)
|
||||
|
||||
## 3. Work Completed
|
||||
- What has been done so far
|
||||
- Files created/modified
|
||||
- Features implemented
|
||||
- Problems solved
|
||||
const capture = async (sessionID: string): Promise<void> => {
|
||||
if (!ctx || !sessionID) {
|
||||
return
|
||||
}
|
||||
|
||||
## 4. Remaining Tasks
|
||||
- What still needs to be done
|
||||
- Pending items from the original request
|
||||
- Follow-up tasks identified during the work
|
||||
const promptConfig = await resolveSessionPromptConfig(ctx, sessionID)
|
||||
if (!promptConfig.agent && !promptConfig.model && !promptConfig.tools) {
|
||||
return
|
||||
}
|
||||
|
||||
## 5. Active Working Context (For Seamless Continuation)
|
||||
- **Files**: Paths of files currently being edited or frequently referenced
|
||||
- **Code in Progress**: Key code snippets, function signatures, or data structures under active development
|
||||
- **External References**: Documentation URLs, library APIs, or external resources being consulted
|
||||
- **State & Variables**: Important variable names, configuration values, or runtime state relevant to ongoing work
|
||||
setCompactionAgentConfigCheckpoint(sessionID, promptConfig)
|
||||
log(`[compaction-context-injector] Captured agent checkpoint before compaction`, {
|
||||
sessionID,
|
||||
agent: promptConfig.agent,
|
||||
model: promptConfig.model,
|
||||
hasTools: !!promptConfig.tools,
|
||||
})
|
||||
}
|
||||
|
||||
## 6. Explicit Constraints (Verbatim Only)
|
||||
- Include ONLY constraints explicitly stated by the user or in existing AGENTS.md context
|
||||
- Quote constraints verbatim (do not paraphrase)
|
||||
- Do NOT invent, add, or modify constraints
|
||||
- If no explicit constraints exist, write "None"
|
||||
|
||||
## 7. Agent Verification State (Critical for Reviewers)
|
||||
- **Current Agent**: What agent is running (momus, oracle, etc.)
|
||||
- **Verification Progress**: Files already verified/validated
|
||||
- **Pending Verifications**: Files still needing verification
|
||||
- **Previous Rejections**: If reviewer agent, what was rejected and why
|
||||
- **Acceptance Status**: Current state of review process
|
||||
|
||||
This section is CRITICAL for reviewer agents (momus, oracle) to maintain continuity.
|
||||
|
||||
## 8. Delegated Agent Sessions
|
||||
- List ALL background agent tasks spawned during this session
|
||||
- For each: agent name, category, status, description, and **session_id**
|
||||
- **RESUME, DON'T RESTART.** Each listed session retains full context. After compaction, use \`session_id\` to continue existing agent sessions instead of spawning new ones. This saves tokens, preserves learned context, and prevents duplicate work.
|
||||
|
||||
This context is critical for maintaining continuity after compaction.
|
||||
`
|
||||
|
||||
export function createCompactionContextInjector(backgroundManager?: BackgroundManager) {
|
||||
return (sessionID?: string): string => {
|
||||
const inject = (sessionID?: string): string => {
|
||||
let prompt = COMPACTION_CONTEXT_PROMPT
|
||||
|
||||
if (backgroundManager && sessionID) {
|
||||
@@ -69,4 +66,99 @@ export function createCompactionContextInjector(backgroundManager?: BackgroundMa
|
||||
|
||||
return prompt
|
||||
}
|
||||
|
||||
const event = async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionID = resolveSessionID(props)
|
||||
if (sessionID) {
|
||||
clearCompactionAgentConfigCheckpoint(sessionID)
|
||||
tailStates.delete(sessionID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
const sessionID = resolveSessionID(props)
|
||||
if (!sessionID) {
|
||||
return
|
||||
}
|
||||
|
||||
const noTextCount = finalizeTrackedAssistantMessage(getTailState(sessionID))
|
||||
if (noTextCount > 0) {
|
||||
await maybeWarnAboutNoTextTail(sessionID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = resolveSessionID(props)
|
||||
if (!sessionID) {
|
||||
return
|
||||
}
|
||||
|
||||
const tailState = getTailState(sessionID)
|
||||
finalizeTrackedAssistantMessage(tailState)
|
||||
tailState.lastCompactedAt = Date.now()
|
||||
await maybeWarnAboutNoTextTail(sessionID)
|
||||
await recoverCheckpointedAgentConfig(sessionID, "session.compacted")
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "message.updated") {
|
||||
const info = props?.info as {
|
||||
id?: string
|
||||
role?: string
|
||||
sessionID?: string
|
||||
} | undefined
|
||||
|
||||
if (!info?.sessionID || info.role !== "assistant" || !info.id) {
|
||||
return
|
||||
}
|
||||
|
||||
const tailState = getTailState(info.sessionID)
|
||||
if (tailState.currentMessageID && tailState.currentMessageID !== info.id) {
|
||||
finalizeTrackedAssistantMessage(tailState)
|
||||
await maybeWarnAboutNoTextTail(info.sessionID)
|
||||
}
|
||||
|
||||
if (tailState.currentMessageID !== info.id) {
|
||||
tailState.currentMessageID = info.id
|
||||
tailState.currentHasOutput = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "message.part.delta") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const messageID = props?.messageID as string | undefined
|
||||
const field = props?.field as string | undefined
|
||||
const delta = props?.delta as string | undefined
|
||||
|
||||
if (!sessionID || field !== "text" || !delta?.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
trackAssistantOutput(getTailState(sessionID), messageID)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "message.part.updated") {
|
||||
const part = props?.part as {
|
||||
messageID?: string
|
||||
sessionID?: string
|
||||
type?: string
|
||||
text?: string
|
||||
} | undefined
|
||||
|
||||
if (!part?.sessionID || !shouldTreatAssistantPartAsOutput(part)) {
|
||||
return
|
||||
}
|
||||
|
||||
trackAssistantOutput(getTailState(part.sessionID), part.messageID)
|
||||
}
|
||||
}
|
||||
|
||||
return { capture, inject, event }
|
||||
}
|
||||
|
||||
@@ -17,6 +17,27 @@ mock.module("../../shared/system-directive", () => ({
|
||||
import { createCompactionContextInjector } from "./index"
|
||||
import { TaskHistory } from "../../features/background-agent/task-history"
|
||||
|
||||
function createMockContext(
|
||||
messageResponses: Array<Array<{ info?: Record<string, unknown> }>>,
|
||||
promptAsyncMock = mock(async () => ({})),
|
||||
) {
|
||||
let callIndex = 0
|
||||
|
||||
return {
|
||||
client: {
|
||||
session: {
|
||||
messages: mock(async () => {
|
||||
const response = messageResponses[Math.min(callIndex, messageResponses.length - 1)] ?? []
|
||||
callIndex += 1
|
||||
return { data: response }
|
||||
}),
|
||||
promptAsync: promptAsyncMock,
|
||||
},
|
||||
},
|
||||
directory: "/tmp/test",
|
||||
}
|
||||
}
|
||||
|
||||
describe("createCompactionContextInjector", () => {
|
||||
describe("Agent Verification State preservation", () => {
|
||||
it("includes Agent Verification State section in compaction prompt", async () => {
|
||||
@@ -24,7 +45,7 @@ describe("createCompactionContextInjector", () => {
|
||||
const injector = createCompactionContextInjector()
|
||||
|
||||
//#when
|
||||
const prompt = injector()
|
||||
const prompt = injector.inject()
|
||||
|
||||
//#then
|
||||
expect(prompt).toContain("Agent Verification State")
|
||||
@@ -37,7 +58,7 @@ describe("createCompactionContextInjector", () => {
|
||||
const injector = createCompactionContextInjector()
|
||||
|
||||
//#when
|
||||
const prompt = injector()
|
||||
const prompt = injector.inject()
|
||||
|
||||
//#then
|
||||
expect(prompt).toContain("Previous Rejections")
|
||||
@@ -50,7 +71,7 @@ describe("createCompactionContextInjector", () => {
|
||||
const injector = createCompactionContextInjector()
|
||||
|
||||
//#when
|
||||
const prompt = injector()
|
||||
const prompt = injector.inject()
|
||||
|
||||
//#then
|
||||
expect(prompt).toContain("Pending Verifications")
|
||||
@@ -63,7 +84,7 @@ describe("createCompactionContextInjector", () => {
|
||||
const injector = createCompactionContextInjector()
|
||||
|
||||
//#when
|
||||
const prompt = injector()
|
||||
const prompt = injector.inject()
|
||||
|
||||
//#then
|
||||
expect(prompt).toContain("Explicit Constraints (Verbatim Only)")
|
||||
@@ -77,7 +98,7 @@ describe("createCompactionContextInjector", () => {
|
||||
const injector = createCompactionContextInjector()
|
||||
|
||||
//#when
|
||||
const prompt = injector()
|
||||
const prompt = injector.inject()
|
||||
|
||||
//#then
|
||||
expect(prompt).toContain("Delegated Agent Sessions")
|
||||
@@ -89,10 +110,10 @@ describe("createCompactionContextInjector", () => {
|
||||
//#given
|
||||
const mockManager = { taskHistory: new TaskHistory() } as any
|
||||
mockManager.taskHistory.record("ses_parent", { id: "t1", sessionID: "ses_child", agent: "explore", description: "Find patterns", status: "completed", category: "quick" })
|
||||
const injector = createCompactionContextInjector(mockManager)
|
||||
const injector = createCompactionContextInjector({ backgroundManager: mockManager })
|
||||
|
||||
//#when
|
||||
const prompt = injector("ses_parent")
|
||||
const prompt = injector.inject("ses_parent")
|
||||
|
||||
//#then
|
||||
expect(prompt).toContain("Active/Recent Delegated Sessions")
|
||||
@@ -104,13 +125,152 @@ describe("createCompactionContextInjector", () => {
|
||||
it("does not inject task history section when no entries exist", async () => {
|
||||
//#given
|
||||
const mockManager = { taskHistory: new TaskHistory() } as any
|
||||
const injector = createCompactionContextInjector(mockManager)
|
||||
const injector = createCompactionContextInjector({ backgroundManager: mockManager })
|
||||
|
||||
//#when
|
||||
const prompt = injector("ses_empty")
|
||||
const prompt = injector.inject("ses_empty")
|
||||
|
||||
//#then
|
||||
expect(prompt).not.toContain("Active/Recent Delegated Sessions")
|
||||
})
|
||||
})
|
||||
|
||||
describe("agent checkpoint recovery", () => {
|
||||
it("re-injects checkpointed agent config after compaction when latest agent is lost", async () => {
|
||||
//#given
|
||||
const promptAsyncMock = mock(async () => ({}))
|
||||
const ctx = createMockContext(
|
||||
[
|
||||
[
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
tools: { bash: "allow" },
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "compaction",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-1" },
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
promptAsyncMock,
|
||||
)
|
||||
const injector = createCompactionContextInjector({ ctx })
|
||||
|
||||
//#when
|
||||
await injector.capture("ses_checkpoint")
|
||||
await injector.event({
|
||||
event: { type: "session.compacted", properties: { sessionID: "ses_checkpoint" } },
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(promptAsyncMock).toHaveBeenCalledWith({
|
||||
path: { id: "ses_checkpoint" },
|
||||
body: {
|
||||
noReply: true,
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
tools: { bash: true },
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: expect.stringContaining("restore checkpointed session agent configuration"),
|
||||
},
|
||||
],
|
||||
},
|
||||
query: { directory: "/tmp/test" },
|
||||
})
|
||||
})
|
||||
|
||||
it("recovers after five consecutive assistant messages with no text", async () => {
|
||||
//#given
|
||||
const promptAsyncMock = mock(async () => ({}))
|
||||
const ctx = createMockContext(
|
||||
[
|
||||
[
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
promptAsyncMock,
|
||||
)
|
||||
const injector = createCompactionContextInjector({ ctx })
|
||||
|
||||
await injector.capture("ses_no_text_tail")
|
||||
await injector.event({
|
||||
event: { type: "session.compacted", properties: { sessionID: "ses_no_text_tail" } },
|
||||
})
|
||||
|
||||
//#when
|
||||
for (let index = 1; index <= 5; index++) {
|
||||
await injector.event({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
id: `msg_${index}`,
|
||||
role: "assistant",
|
||||
sessionID: "ses_no_text_tail",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
await injector.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "ses_no_text_tail" } },
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(promptAsyncMock).toHaveBeenCalledTimes(1)
|
||||
expect(promptAsyncMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: { id: "ses_no_text_tail" },
|
||||
body: expect.objectContaining({
|
||||
noReply: true,
|
||||
agent: "atlas",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { CompactionAgentConfigCheckpoint } from "../../shared/compaction-agent-config-checkpoint"
|
||||
|
||||
export type RecoveryPromptConfig = CompactionAgentConfigCheckpoint & {
|
||||
agent: string
|
||||
}
|
||||
|
||||
function isCompactionAgent(agent: string | undefined): boolean {
|
||||
return agent?.trim().toLowerCase() === "compaction"
|
||||
}
|
||||
|
||||
function matchesExpectedModel(
|
||||
actualModel: CompactionAgentConfigCheckpoint["model"],
|
||||
expectedModel: CompactionAgentConfigCheckpoint["model"],
|
||||
): boolean {
|
||||
if (!expectedModel) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
actualModel?.providerID === expectedModel.providerID &&
|
||||
actualModel.modelID === expectedModel.modelID
|
||||
)
|
||||
}
|
||||
|
||||
function matchesExpectedTools(
|
||||
actualTools: CompactionAgentConfigCheckpoint["tools"],
|
||||
expectedTools: CompactionAgentConfigCheckpoint["tools"],
|
||||
): boolean {
|
||||
if (!expectedTools) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!actualTools) {
|
||||
return false
|
||||
}
|
||||
|
||||
const expectedEntries = Object.entries(expectedTools)
|
||||
if (expectedEntries.length !== Object.keys(actualTools).length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return expectedEntries.every(
|
||||
([toolName, isAllowed]) => actualTools[toolName] === isAllowed,
|
||||
)
|
||||
}
|
||||
|
||||
export function createExpectedRecoveryPromptConfig(
|
||||
checkpoint: Pick<RecoveryPromptConfig, "agent"> & CompactionAgentConfigCheckpoint,
|
||||
currentPromptConfig: CompactionAgentConfigCheckpoint,
|
||||
): RecoveryPromptConfig {
|
||||
const model = checkpoint.model ?? currentPromptConfig.model
|
||||
const tools = checkpoint.tools ?? currentPromptConfig.tools
|
||||
|
||||
return {
|
||||
agent: checkpoint.agent,
|
||||
...(model ? { model } : {}),
|
||||
...(tools ? { tools } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function isPromptConfigRecovered(
|
||||
actualPromptConfig: CompactionAgentConfigCheckpoint,
|
||||
expectedPromptConfig: RecoveryPromptConfig,
|
||||
): boolean {
|
||||
const actualAgent = actualPromptConfig.agent
|
||||
const agentMatches =
|
||||
typeof actualAgent === "string" &&
|
||||
!isCompactionAgent(actualAgent) &&
|
||||
actualAgent.toLowerCase() === expectedPromptConfig.agent.toLowerCase()
|
||||
|
||||
return (
|
||||
agentMatches &&
|
||||
matchesExpectedModel(actualPromptConfig.model, expectedPromptConfig.model) &&
|
||||
matchesExpectedTools(actualPromptConfig.tools, expectedPromptConfig.tools)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
/// <reference path="../../../bun-test.d.ts" />
|
||||
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { setCompactionAgentConfigCheckpoint } from "../../shared/compaction-agent-config-checkpoint"
|
||||
import { createCompactionContextInjector } from "./index"
|
||||
|
||||
type SessionMessageResponse = Array<{
|
||||
info?: Record<string, unknown>
|
||||
}>
|
||||
|
||||
type PromptAsyncInput = {
|
||||
path: { id: string }
|
||||
body: {
|
||||
noReply?: boolean
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
tools?: Record<string, boolean>
|
||||
parts: Array<{ type: "text"; text: string }>
|
||||
}
|
||||
query?: { directory: string }
|
||||
}
|
||||
|
||||
function createPromptAsyncRecorder(): {
|
||||
calls: PromptAsyncInput[]
|
||||
promptAsync: (input: PromptAsyncInput) => Promise<Record<string, never>>
|
||||
} {
|
||||
const calls: PromptAsyncInput[] = []
|
||||
|
||||
return {
|
||||
calls,
|
||||
promptAsync: async (input: PromptAsyncInput) => {
|
||||
calls.push(input)
|
||||
return {}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createMockContext(
|
||||
messageResponses: SessionMessageResponse[],
|
||||
promptAsync: (input: PromptAsyncInput) => Promise<Record<string, never>>,
|
||||
) {
|
||||
let callIndex = 0
|
||||
|
||||
return {
|
||||
client: {
|
||||
session: {
|
||||
messages: async () => {
|
||||
const response =
|
||||
messageResponses[Math.min(callIndex, messageResponses.length - 1)] ?? []
|
||||
callIndex += 1
|
||||
return { data: response }
|
||||
},
|
||||
promptAsync,
|
||||
},
|
||||
},
|
||||
directory: "/tmp/test",
|
||||
}
|
||||
}
|
||||
|
||||
function createAssistantMessageUpdatedEvent(sessionID: string, messageID: string) {
|
||||
return {
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
id: messageID,
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const
|
||||
}
|
||||
|
||||
function createMeaningfulPartUpdatedEvent(
|
||||
sessionID: string,
|
||||
messageID: string,
|
||||
type: "reasoning" | "tool_use",
|
||||
) {
|
||||
return {
|
||||
event: {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
messageID,
|
||||
sessionID,
|
||||
type,
|
||||
...(type === "reasoning" ? { text: "thinking" } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const
|
||||
}
|
||||
|
||||
describe("createCompactionContextInjector recovery", () => {
|
||||
it("re-injects after compaction when agent and model match but tools are missing", async () => {
|
||||
//#given
|
||||
const promptAsyncRecorder = createPromptAsyncRecorder()
|
||||
const ctx = createMockContext(
|
||||
[
|
||||
[
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
tools: { bash: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
tools: { bash: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
promptAsyncRecorder.promptAsync,
|
||||
)
|
||||
const injector = createCompactionContextInjector({ ctx })
|
||||
|
||||
//#when
|
||||
await injector.capture("ses_missing_tools")
|
||||
await injector.event({
|
||||
event: { type: "session.compacted", properties: { sessionID: "ses_missing_tools" } },
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(promptAsyncRecorder.calls.length).toBe(1)
|
||||
expect(promptAsyncRecorder.calls[0]?.body.agent).toBe("atlas")
|
||||
expect(promptAsyncRecorder.calls[0]?.body.model).toEqual({
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5",
|
||||
})
|
||||
expect(promptAsyncRecorder.calls[0]?.body.tools).toEqual({ bash: true })
|
||||
})
|
||||
|
||||
it("retries recovery when the recovered prompt config still mismatches expected model or tools", async () => {
|
||||
//#given
|
||||
const promptAsyncRecorder = createPromptAsyncRecorder()
|
||||
const mismatchResponse = [
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-4.1" },
|
||||
},
|
||||
},
|
||||
]
|
||||
const ctx = createMockContext(
|
||||
[
|
||||
[
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
tools: { bash: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
mismatchResponse,
|
||||
mismatchResponse,
|
||||
mismatchResponse,
|
||||
mismatchResponse,
|
||||
mismatchResponse,
|
||||
mismatchResponse,
|
||||
],
|
||||
promptAsyncRecorder.promptAsync,
|
||||
)
|
||||
const injector = createCompactionContextInjector({ ctx })
|
||||
|
||||
//#when
|
||||
await injector.capture("ses_retry_incomplete_recovery")
|
||||
await injector.event({
|
||||
event: {
|
||||
type: "session.compacted",
|
||||
properties: { sessionID: "ses_retry_incomplete_recovery" },
|
||||
},
|
||||
})
|
||||
await injector.event({
|
||||
event: {
|
||||
type: "session.compacted",
|
||||
properties: { sessionID: "ses_retry_incomplete_recovery" },
|
||||
},
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(promptAsyncRecorder.calls.length).toBe(2)
|
||||
})
|
||||
|
||||
it("does not treat reasoning-only assistant messages as a no-text tail", async () => {
|
||||
//#given
|
||||
const promptAsyncRecorder = createPromptAsyncRecorder()
|
||||
const matchingPromptConfig = [
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
tools: { bash: true },
|
||||
},
|
||||
},
|
||||
]
|
||||
const ctx = createMockContext(
|
||||
[matchingPromptConfig, matchingPromptConfig, matchingPromptConfig],
|
||||
promptAsyncRecorder.promptAsync,
|
||||
)
|
||||
const injector = createCompactionContextInjector({ ctx })
|
||||
const sessionID = "ses_reasoning_tail"
|
||||
|
||||
await injector.capture(sessionID)
|
||||
await injector.event({
|
||||
event: { type: "session.compacted", properties: { sessionID } },
|
||||
})
|
||||
|
||||
//#when
|
||||
for (let index = 1; index <= 5; index++) {
|
||||
const messageID = `msg_reasoning_${index}`
|
||||
await injector.event(createAssistantMessageUpdatedEvent(sessionID, messageID))
|
||||
await injector.event(
|
||||
createMeaningfulPartUpdatedEvent(sessionID, messageID, "reasoning"),
|
||||
)
|
||||
await injector.event({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
}
|
||||
|
||||
//#then
|
||||
expect(promptAsyncRecorder.calls.length).toBe(0)
|
||||
})
|
||||
|
||||
it("does not treat tool_use-only assistant messages as a no-text tail", async () => {
|
||||
//#given
|
||||
const promptAsyncRecorder = createPromptAsyncRecorder()
|
||||
const matchingPromptConfig = [
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
tools: { bash: true },
|
||||
},
|
||||
},
|
||||
]
|
||||
const ctx = createMockContext(
|
||||
[matchingPromptConfig, matchingPromptConfig, matchingPromptConfig],
|
||||
promptAsyncRecorder.promptAsync,
|
||||
)
|
||||
const injector = createCompactionContextInjector({ ctx })
|
||||
const sessionID = "ses_tool_use_tail"
|
||||
|
||||
await injector.capture(sessionID)
|
||||
await injector.event({
|
||||
event: { type: "session.compacted", properties: { sessionID } },
|
||||
})
|
||||
|
||||
//#when
|
||||
for (let index = 1; index <= 5; index++) {
|
||||
const messageID = `msg_tool_use_${index}`
|
||||
await injector.event(createAssistantMessageUpdatedEvent(sessionID, messageID))
|
||||
await injector.event(
|
||||
createMeaningfulPartUpdatedEvent(sessionID, messageID, "tool_use"),
|
||||
)
|
||||
await injector.event({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
}
|
||||
|
||||
//#then
|
||||
expect(promptAsyncRecorder.calls.length).toBe(0)
|
||||
})
|
||||
|
||||
it("falls back to the current non-compaction model when a checkpoint model is poisoned", async () => {
|
||||
//#given
|
||||
const sessionID = "ses_poisoned_checkpoint_model"
|
||||
const promptAsyncRecorder = createPromptAsyncRecorder()
|
||||
setCompactionAgentConfigCheckpoint(sessionID, {
|
||||
agent: "atlas",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-1" },
|
||||
tools: { bash: true },
|
||||
})
|
||||
const ctx = createMockContext(
|
||||
[
|
||||
[
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
tools: { bash: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "compaction",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-1" },
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "compaction",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-1" },
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
tools: { bash: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
promptAsyncRecorder.promptAsync,
|
||||
)
|
||||
const injector = createCompactionContextInjector({ ctx })
|
||||
|
||||
//#when
|
||||
await injector.event({
|
||||
event: { type: "session.compacted", properties: { sessionID } },
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(promptAsyncRecorder.calls.length).toBe(1)
|
||||
expect(promptAsyncRecorder.calls[0]?.body.model).toEqual({
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5",
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,159 @@
|
||||
import { updateSessionAgent } from "../../features/claude-code-session-state"
|
||||
import {
|
||||
getCompactionAgentConfigCheckpoint,
|
||||
} from "../../shared/compaction-agent-config-checkpoint"
|
||||
import { createInternalAgentTextPart } from "../../shared/internal-initiator-marker"
|
||||
import { log } from "../../shared/logger"
|
||||
import { setSessionModel } from "../../shared/session-model-state"
|
||||
import { setSessionTools } from "../../shared/session-tools-store"
|
||||
import {
|
||||
createExpectedRecoveryPromptConfig,
|
||||
isPromptConfigRecovered,
|
||||
} from "./recovery-prompt-config"
|
||||
import { validateCheckpointModel } from "./validated-model"
|
||||
import {
|
||||
resolveLatestSessionPromptConfig,
|
||||
resolveSessionPromptConfig,
|
||||
} from "./session-prompt-config-resolver"
|
||||
import { AGENT_RECOVERY_PROMPT, NO_TEXT_TAIL_THRESHOLD, RECOVERY_COOLDOWN_MS, RECENT_COMPACTION_WINDOW_MS } from "./constants"
|
||||
import type { CompactionContextClient } from "./types"
|
||||
import type { TailMonitorState } from "./tail-monitor"
|
||||
|
||||
export function createRecoveryLogic(
|
||||
ctx: CompactionContextClient | undefined,
|
||||
getTailState: (sessionID: string) => TailMonitorState,
|
||||
) {
|
||||
const recoverCheckpointedAgentConfig = async (
|
||||
sessionID: string,
|
||||
reason: "session.compacted" | "no-text-tail",
|
||||
): Promise<boolean> => {
|
||||
if (!ctx) {
|
||||
return false
|
||||
}
|
||||
|
||||
const checkpoint = getCompactionAgentConfigCheckpoint(sessionID)
|
||||
if (!checkpoint?.agent) {
|
||||
return false
|
||||
}
|
||||
|
||||
const tailState = getTailState(sessionID)
|
||||
const now = Date.now()
|
||||
if (tailState.lastRecoveryAt && now - tailState.lastRecoveryAt < RECOVERY_COOLDOWN_MS) {
|
||||
return false
|
||||
}
|
||||
|
||||
const currentPromptConfig = await resolveSessionPromptConfig(ctx, sessionID)
|
||||
const validatedCheckpointModel = validateCheckpointModel(
|
||||
checkpoint.model,
|
||||
currentPromptConfig.model,
|
||||
)
|
||||
const { model: checkpointModel, ...checkpointWithoutModel } = checkpoint
|
||||
const checkpointWithAgent = {
|
||||
...checkpointWithoutModel,
|
||||
agent: checkpoint.agent,
|
||||
...(validatedCheckpointModel ? { model: validatedCheckpointModel } : {}),
|
||||
}
|
||||
|
||||
if (checkpointModel && !validatedCheckpointModel) {
|
||||
log(`[compaction-context-injector] Ignoring checkpoint model that disagrees with current prompt config`, {
|
||||
sessionID,
|
||||
checkpointModel,
|
||||
currentModel: currentPromptConfig.model,
|
||||
})
|
||||
}
|
||||
|
||||
const expectedPromptConfig = createExpectedRecoveryPromptConfig(
|
||||
checkpointWithAgent,
|
||||
currentPromptConfig,
|
||||
)
|
||||
const model = expectedPromptConfig.model
|
||||
const tools = expectedPromptConfig.tools
|
||||
|
||||
if (reason === "session.compacted") {
|
||||
const latestPromptConfig = await resolveLatestSessionPromptConfig(ctx, sessionID)
|
||||
if (isPromptConfigRecovered(latestPromptConfig, expectedPromptConfig)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await ctx.client.session.promptAsync({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
noReply: true,
|
||||
agent: expectedPromptConfig.agent,
|
||||
...(model ? { model } : {}),
|
||||
...(tools ? { tools } : {}),
|
||||
parts: [createInternalAgentTextPart(AGENT_RECOVERY_PROMPT)],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
|
||||
const recoveredPromptConfig = await resolveLatestSessionPromptConfig(ctx, sessionID)
|
||||
if (!isPromptConfigRecovered(recoveredPromptConfig, expectedPromptConfig)) {
|
||||
log(`[compaction-context-injector] Re-injected agent config but recovery is still incomplete`, {
|
||||
sessionID,
|
||||
reason,
|
||||
agent: expectedPromptConfig.agent,
|
||||
model,
|
||||
hasTools: !!tools,
|
||||
recoveredPromptConfig,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
updateSessionAgent(sessionID, expectedPromptConfig.agent)
|
||||
if (model) {
|
||||
setSessionModel(sessionID, model)
|
||||
}
|
||||
if (tools) {
|
||||
setSessionTools(sessionID, tools)
|
||||
}
|
||||
|
||||
tailState.lastRecoveryAt = now
|
||||
tailState.consecutiveNoTextMessages = 0
|
||||
|
||||
log(`[compaction-context-injector] Re-injected checkpointed agent config`, {
|
||||
sessionID,
|
||||
reason,
|
||||
agent: expectedPromptConfig.agent,
|
||||
model,
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
log(`[compaction-context-injector] Failed to re-inject checkpointed agent config`, {
|
||||
sessionID,
|
||||
reason,
|
||||
error: String(error),
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const maybeWarnAboutNoTextTail = async (sessionID: string): Promise<void> => {
|
||||
const tailState = getTailState(sessionID)
|
||||
if (tailState.consecutiveNoTextMessages < NO_TEXT_TAIL_THRESHOLD) {
|
||||
return
|
||||
}
|
||||
|
||||
const recentlyCompacted =
|
||||
tailState.lastCompactedAt !== undefined &&
|
||||
Date.now() - tailState.lastCompactedAt < RECENT_COMPACTION_WINDOW_MS
|
||||
|
||||
log(`[compaction-context-injector] Detected consecutive assistant messages with no text`, {
|
||||
sessionID,
|
||||
consecutiveNoTextMessages: tailState.consecutiveNoTextMessages,
|
||||
recentlyCompacted,
|
||||
})
|
||||
|
||||
if (recentlyCompacted) {
|
||||
await recoverCheckpointedAgentConfig(sessionID, "no-text-tail")
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
recoverCheckpointedAgentConfig,
|
||||
maybeWarnAboutNoTextTail,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function isCompactionAgent(agent: string | undefined): boolean {
|
||||
return agent?.trim().toLowerCase() === "compaction"
|
||||
}
|
||||
|
||||
export function resolveSessionID(props?: Record<string, unknown>): string | undefined {
|
||||
return (props?.sessionID ??
|
||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test"
|
||||
|
||||
import { _resetForTesting } from "../../features/claude-code-session-state"
|
||||
import { clearSessionModel, setSessionModel } from "../../shared/session-model-state"
|
||||
import { clearSessionTools } from "../../shared/session-tools-store"
|
||||
import {
|
||||
resolveLatestSessionPromptConfig,
|
||||
resolveSessionPromptConfig,
|
||||
} from "./session-prompt-config-resolver"
|
||||
|
||||
type SessionMessage = {
|
||||
info?: {
|
||||
agent?: string
|
||||
model?: {
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
}
|
||||
tools?: Record<string, boolean | "allow" | "deny" | "ask">
|
||||
}
|
||||
}
|
||||
|
||||
function createMockContext(messages: SessionMessage[]) {
|
||||
return {
|
||||
client: {
|
||||
session: {
|
||||
messages: async () => ({ data: messages }),
|
||||
},
|
||||
},
|
||||
directory: "/tmp/test",
|
||||
}
|
||||
}
|
||||
|
||||
describe("session prompt config resolver", () => {
|
||||
const sessionID = "ses_compaction_model_validation"
|
||||
|
||||
afterEach(() => {
|
||||
_resetForTesting()
|
||||
clearSessionModel(sessionID)
|
||||
clearSessionTools()
|
||||
})
|
||||
|
||||
it("prefers the latest non-compaction model over poisoned session state", async () => {
|
||||
// given
|
||||
setSessionModel(sessionID, {
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-opus-4-1",
|
||||
})
|
||||
const ctx = createMockContext([
|
||||
{
|
||||
info: {
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
tools: { bash: "allow" },
|
||||
},
|
||||
},
|
||||
{
|
||||
info: {
|
||||
agent: "compaction",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-1" },
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
// when
|
||||
const promptConfig = await resolveSessionPromptConfig(ctx, sessionID)
|
||||
|
||||
// then
|
||||
expect(promptConfig).toEqual({
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
tools: { bash: true },
|
||||
})
|
||||
})
|
||||
|
||||
it("omits a compaction model from the latest prompt config", async () => {
|
||||
// given
|
||||
const ctx = createMockContext([
|
||||
{
|
||||
info: {
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
},
|
||||
},
|
||||
{
|
||||
info: {
|
||||
agent: "compaction",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-1" },
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
// when
|
||||
const promptConfig = await resolveLatestSessionPromptConfig(ctx, sessionID)
|
||||
|
||||
// then
|
||||
expect(promptConfig).toEqual({ agent: "compaction" })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import { getSessionAgent } from "../../features/claude-code-session-state"
|
||||
import type { CompactionAgentConfigCheckpoint } from "../../shared/compaction-agent-config-checkpoint"
|
||||
import { log } from "../../shared/logger"
|
||||
import { normalizeSDKResponse } from "../../shared/normalize-sdk-response"
|
||||
import { normalizePromptTools } from "../../shared/prompt-tools"
|
||||
import { getSessionModel } from "../../shared/session-model-state"
|
||||
import { getSessionTools } from "../../shared/session-tools-store"
|
||||
import { isCompactionAgent } from "./session-id"
|
||||
import { resolveValidatedModel } from "./validated-model"
|
||||
|
||||
type SessionMessage = {
|
||||
info?: {
|
||||
agent?: string
|
||||
model?: {
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
}
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
tools?: Record<string, boolean | "allow" | "deny" | "ask">
|
||||
}
|
||||
}
|
||||
|
||||
type ResolverContext = {
|
||||
client: {
|
||||
session: {
|
||||
messages: (input: { path: { id: string } }) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
directory: string
|
||||
}
|
||||
|
||||
export async function resolveSessionPromptConfig(
|
||||
ctx: ResolverContext,
|
||||
sessionID: string,
|
||||
): Promise<CompactionAgentConfigCheckpoint> {
|
||||
const storedModel = getSessionModel(sessionID)
|
||||
const promptConfig: CompactionAgentConfigCheckpoint = {
|
||||
agent: getSessionAgent(sessionID),
|
||||
tools: getSessionTools(sessionID),
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await ctx.client.session.messages({ path: { id: sessionID } })
|
||||
const messages = normalizeSDKResponse(response, [] as SessionMessage[], {
|
||||
preferResponseOnMissingData: true,
|
||||
})
|
||||
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
const info = messages[index].info
|
||||
|
||||
if (!promptConfig.agent && info?.agent && !isCompactionAgent(info.agent)) {
|
||||
promptConfig.agent = info.agent
|
||||
}
|
||||
|
||||
if (!promptConfig.model) {
|
||||
const model = resolveValidatedModel(info)
|
||||
if (model) {
|
||||
promptConfig.model = model
|
||||
}
|
||||
}
|
||||
|
||||
if (!promptConfig.tools) {
|
||||
const tools = normalizePromptTools(info?.tools)
|
||||
if (tools) {
|
||||
promptConfig.tools = tools
|
||||
}
|
||||
}
|
||||
|
||||
if (promptConfig.agent && promptConfig.model && promptConfig.tools) {
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log("[compaction-context-injector] Failed to resolve prompt config from messages", {
|
||||
sessionID,
|
||||
directory: ctx.directory,
|
||||
error: String(error),
|
||||
})
|
||||
}
|
||||
|
||||
if (!promptConfig.model && storedModel) {
|
||||
promptConfig.model = storedModel
|
||||
}
|
||||
|
||||
return promptConfig
|
||||
}
|
||||
|
||||
export async function resolveLatestSessionPromptConfig(
|
||||
ctx: ResolverContext,
|
||||
sessionID: string,
|
||||
): Promise<CompactionAgentConfigCheckpoint> {
|
||||
try {
|
||||
const response = await ctx.client.session.messages({ path: { id: sessionID } })
|
||||
const messages = normalizeSDKResponse(response, [] as SessionMessage[], {
|
||||
preferResponseOnMissingData: true,
|
||||
})
|
||||
const latestInfo = messages.at(-1)?.info
|
||||
|
||||
if (!latestInfo) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const model = resolveValidatedModel(latestInfo)
|
||||
const tools = normalizePromptTools(latestInfo.tools)
|
||||
|
||||
return {
|
||||
...(latestInfo.agent ? { agent: latestInfo.agent } : {}),
|
||||
...(model ? { model } : {}),
|
||||
...(tools ? { tools } : {}),
|
||||
}
|
||||
} catch (error) {
|
||||
log("[compaction-context-injector] Failed to resolve latest prompt config", {
|
||||
sessionID,
|
||||
directory: ctx.directory,
|
||||
error: String(error),
|
||||
})
|
||||
return {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
const MEANINGFUL_ASSISTANT_PART_TYPES = new Set([
|
||||
"reasoning",
|
||||
"tool",
|
||||
"tool_use",
|
||||
])
|
||||
|
||||
export type TailMonitorState = {
|
||||
currentMessageID?: string
|
||||
currentHasOutput: boolean
|
||||
consecutiveNoTextMessages: number
|
||||
lastCompactedAt?: number
|
||||
lastRecoveryAt?: number
|
||||
}
|
||||
|
||||
export function finalizeTrackedAssistantMessage(
|
||||
state: TailMonitorState,
|
||||
): number {
|
||||
if (!state.currentMessageID) {
|
||||
return state.consecutiveNoTextMessages
|
||||
}
|
||||
|
||||
state.consecutiveNoTextMessages = state.currentHasOutput
|
||||
? 0
|
||||
: state.consecutiveNoTextMessages + 1
|
||||
state.currentMessageID = undefined
|
||||
state.currentHasOutput = false
|
||||
|
||||
return state.consecutiveNoTextMessages
|
||||
}
|
||||
|
||||
export function shouldTreatAssistantPartAsOutput(part: {
|
||||
type?: string
|
||||
text?: string
|
||||
}): boolean {
|
||||
if (part.type === "text") {
|
||||
return !!part.text?.trim()
|
||||
}
|
||||
|
||||
return typeof part.type === "string" && MEANINGFUL_ASSISTANT_PART_TYPES.has(part.type)
|
||||
}
|
||||
|
||||
export function trackAssistantOutput(
|
||||
state: TailMonitorState,
|
||||
messageID?: string,
|
||||
): void {
|
||||
if (messageID && !state.currentMessageID) {
|
||||
state.currentMessageID = messageID
|
||||
}
|
||||
|
||||
state.currentHasOutput = true
|
||||
state.consecutiveNoTextMessages = 0
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface CompactionContextInjector {
|
||||
capture: (sessionID: string) => Promise<void>
|
||||
inject: (sessionID?: string) => string
|
||||
event: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
|
||||
}
|
||||
|
||||
export type CompactionContextClient = {
|
||||
client: {
|
||||
session: {
|
||||
messages: (input: { path: { id: string } }) => Promise<unknown>
|
||||
promptAsync: (input: {
|
||||
path: { id: string }
|
||||
body: {
|
||||
noReply?: boolean
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
tools?: Record<string, boolean>
|
||||
parts: Array<{ type: "text"; text: string }>
|
||||
}
|
||||
query?: { directory: string }
|
||||
}) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
directory: string
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { CompactionAgentConfigCheckpoint } from "../../shared/compaction-agent-config-checkpoint"
|
||||
import { isCompactionAgent } from "./session-id"
|
||||
|
||||
type PromptConfigInfo = {
|
||||
agent?: string
|
||||
model?: {
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
}
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
}
|
||||
|
||||
export function resolveValidatedModel(
|
||||
info: PromptConfigInfo | undefined,
|
||||
): CompactionAgentConfigCheckpoint["model"] | undefined {
|
||||
if (isCompactionAgent(info?.agent)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const providerID = info?.model?.providerID ?? info?.providerID
|
||||
const modelID = info?.model?.modelID ?? info?.modelID
|
||||
|
||||
if (!providerID || !modelID) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return { providerID, modelID }
|
||||
}
|
||||
|
||||
export function validateCheckpointModel(
|
||||
checkpointModel: CompactionAgentConfigCheckpoint["model"],
|
||||
currentModel: CompactionAgentConfigCheckpoint["model"],
|
||||
): CompactionAgentConfigCheckpoint["model"] | undefined {
|
||||
if (!checkpointModel) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!currentModel) {
|
||||
return checkpointModel
|
||||
}
|
||||
|
||||
return checkpointModel.providerID === currentModel.providerID &&
|
||||
checkpointModel.modelID === currentModel.modelID
|
||||
? checkpointModel
|
||||
: undefined
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { createContextWindowMonitorHook } from "./context-window-monitor"
|
||||
|
||||
function createOutput() {
|
||||
return { title: "", output: "original", metadata: null }
|
||||
}
|
||||
|
||||
describe("context-window-monitor modelContextLimitsCache", () => {
|
||||
it("does not append reminder below cached non-anthropic threshold", async () => {
|
||||
// given
|
||||
const modelContextLimitsCache = new Map<string, number>()
|
||||
modelContextLimitsCache.set("opencode/kimi-k2.5-free", 262144)
|
||||
|
||||
const hook = createContextWindowMonitorHook({} as never, {
|
||||
anthropicContext1MEnabled: false,
|
||||
modelContextLimitsCache,
|
||||
})
|
||||
const sessionID = "ses_non_anthropic_below_threshold"
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
providerID: "opencode",
|
||||
modelID: "kimi-k2.5-free",
|
||||
finish: true,
|
||||
tokens: {
|
||||
input: 150000,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 10000, write: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// when
|
||||
const output = createOutput()
|
||||
await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "call_1" }, output)
|
||||
|
||||
// then
|
||||
expect(output.output).toBe("original")
|
||||
})
|
||||
|
||||
it("appends reminder above cached non-anthropic threshold", async () => {
|
||||
// given
|
||||
const modelContextLimitsCache = new Map<string, number>()
|
||||
modelContextLimitsCache.set("opencode/kimi-k2.5-free", 262144)
|
||||
|
||||
const hook = createContextWindowMonitorHook({} as never, {
|
||||
anthropicContext1MEnabled: false,
|
||||
modelContextLimitsCache,
|
||||
})
|
||||
const sessionID = "ses_non_anthropic_above_threshold"
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
providerID: "opencode",
|
||||
modelID: "kimi-k2.5-free",
|
||||
finish: true,
|
||||
tokens: {
|
||||
input: 180000,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 10000, write: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// when
|
||||
const output = createOutput()
|
||||
await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "call_1" }, output)
|
||||
|
||||
// then
|
||||
expect(output.output).toContain("context remaining")
|
||||
expect(output.output).toContain("262,144-token context window")
|
||||
expect(output.output).toContain("[Context Status: 72.5% used (190,000/262,144 tokens), 27.5% remaining]")
|
||||
expect(output.output).not.toContain("1,000,000")
|
||||
})
|
||||
|
||||
describe("#given Anthropic provider with cached context limit and 1M mode enabled", () => {
|
||||
describe("#when cached usage would exceed 200K but stay below 1M", () => {
|
||||
it("#then should ignore the cached limit and skip the reminder", async () => {
|
||||
// given
|
||||
const modelContextLimitsCache = new Map<string, number>()
|
||||
modelContextLimitsCache.set("anthropic/claude-sonnet-4-5", 200000)
|
||||
|
||||
const hook = createContextWindowMonitorHook({} as never, {
|
||||
anthropicContext1MEnabled: true,
|
||||
modelContextLimitsCache,
|
||||
})
|
||||
const sessionID = "ses_anthropic_1m_overrides_cached_limit"
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-5",
|
||||
finish: true,
|
||||
tokens: {
|
||||
input: 300000,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// when
|
||||
const output = createOutput()
|
||||
await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "call_1" }, output)
|
||||
|
||||
// then
|
||||
expect(output.output).toBe("original")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given Anthropic provider with cached context limit and 1M mode disabled", () => {
|
||||
describe("#when cached usage exceeds the Anthropic default limit", () => {
|
||||
it("#then should ignore the cached limit and append the reminder from the default Anthropic limit", async () => {
|
||||
// given
|
||||
const modelContextLimitsCache = new Map<string, number>()
|
||||
modelContextLimitsCache.set("anthropic/claude-sonnet-4-5", 500000)
|
||||
|
||||
const hook = createContextWindowMonitorHook({} as never, {
|
||||
anthropicContext1MEnabled: false,
|
||||
modelContextLimitsCache,
|
||||
})
|
||||
const sessionID = "ses_anthropic_default_overrides_cached_limit"
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-5",
|
||||
finish: true,
|
||||
tokens: {
|
||||
input: 150000,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 10000, write: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// when
|
||||
const output = createOutput()
|
||||
await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "call_1" }, output)
|
||||
|
||||
// then
|
||||
expect(output.output).toContain("context remaining")
|
||||
expect(output.output).toContain("200,000-token context window")
|
||||
expect(output.output).not.toContain("500,000-token context window")
|
||||
expect(output.output).not.toContain("1,000,000-token context window")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,27 +1,21 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import {
|
||||
resolveActualContextLimit,
|
||||
type ContextLimitModelCacheState,
|
||||
} from "../shared/context-limit-resolver"
|
||||
import { createSystemDirective, SystemDirectiveTypes } from "../shared/system-directive"
|
||||
|
||||
const ANTHROPIC_DISPLAY_LIMIT = 1_000_000
|
||||
const DEFAULT_ANTHROPIC_ACTUAL_LIMIT = 200_000
|
||||
const CONTEXT_WARNING_THRESHOLD = 0.70
|
||||
|
||||
type ModelCacheStateLike = {
|
||||
anthropicContext1MEnabled: boolean
|
||||
}
|
||||
function createContextReminder(actualLimit: number): string {
|
||||
const limitTokens = actualLimit.toLocaleString()
|
||||
|
||||
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_ANTHROPIC_ACTUAL_LIMIT
|
||||
}
|
||||
return `${createSystemDirective(SystemDirectiveTypes.CONTEXT_WINDOW_MONITOR)}
|
||||
|
||||
const CONTEXT_REMINDER = `${createSystemDirective(SystemDirectiveTypes.CONTEXT_WINDOW_MONITOR)}
|
||||
|
||||
You are using Anthropic Claude with 1M context window.
|
||||
You have plenty of context remaining - do NOT rush or skip tasks.
|
||||
You are using a ${limitTokens}-token context window.
|
||||
You still have context remaining - do NOT rush or skip tasks.
|
||||
Complete your work thoroughly and methodically.`
|
||||
}
|
||||
|
||||
interface TokenInfo {
|
||||
input: number
|
||||
@@ -32,16 +26,13 @@ interface TokenInfo {
|
||||
|
||||
interface CachedTokenState {
|
||||
providerID: string
|
||||
modelID: string
|
||||
tokens: TokenInfo
|
||||
}
|
||||
|
||||
function isAnthropicProvider(providerID: string): boolean {
|
||||
return providerID === "anthropic" || providerID === "google-vertex-anthropic"
|
||||
}
|
||||
|
||||
export function createContextWindowMonitorHook(
|
||||
_ctx: PluginInput,
|
||||
modelCacheState?: ModelCacheStateLike,
|
||||
modelCacheState?: ContextLimitModelCacheState,
|
||||
) {
|
||||
const remindedSessions = new Set<string>()
|
||||
const tokenCache = new Map<string, CachedTokenState>()
|
||||
@@ -57,25 +48,29 @@ export function createContextWindowMonitorHook(
|
||||
const cached = tokenCache.get(sessionID)
|
||||
if (!cached) return
|
||||
|
||||
if (!isAnthropicProvider(cached.providerID)) return
|
||||
const actualLimit = resolveActualContextLimit(
|
||||
cached.providerID,
|
||||
cached.modelID,
|
||||
modelCacheState,
|
||||
)
|
||||
|
||||
if (!actualLimit) return
|
||||
|
||||
const lastTokens = cached.tokens
|
||||
const totalInputTokens = (lastTokens?.input ?? 0) + (lastTokens?.cache?.read ?? 0)
|
||||
|
||||
const actualUsagePercentage =
|
||||
totalInputTokens / getAnthropicActualLimit(modelCacheState)
|
||||
const actualUsagePercentage = totalInputTokens / actualLimit
|
||||
|
||||
if (actualUsagePercentage < CONTEXT_WARNING_THRESHOLD) return
|
||||
|
||||
remindedSessions.add(sessionID)
|
||||
|
||||
const displayUsagePercentage = totalInputTokens / ANTHROPIC_DISPLAY_LIMIT
|
||||
const usedPct = (displayUsagePercentage * 100).toFixed(1)
|
||||
const remainingPct = ((1 - displayUsagePercentage) * 100).toFixed(1)
|
||||
const usedPct = (actualUsagePercentage * 100).toFixed(1)
|
||||
const remainingPct = ((1 - actualUsagePercentage) * 100).toFixed(1)
|
||||
const usedTokens = totalInputTokens.toLocaleString()
|
||||
const limitTokens = ANTHROPIC_DISPLAY_LIMIT.toLocaleString()
|
||||
const limitTokens = actualLimit.toLocaleString()
|
||||
|
||||
output.output += `\n\n${CONTEXT_REMINDER}
|
||||
output.output += `\n\n${createContextReminder(actualLimit)}
|
||||
[Context Status: ${usedPct}% used (${usedTokens}/${limitTokens} tokens), ${remainingPct}% remaining]`
|
||||
}
|
||||
|
||||
@@ -95,6 +90,7 @@ export function createContextWindowMonitorHook(
|
||||
role?: string
|
||||
sessionID?: string
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
finish?: boolean
|
||||
tokens?: TokenInfo
|
||||
} | undefined
|
||||
@@ -104,6 +100,7 @@ export function createContextWindowMonitorHook(
|
||||
|
||||
tokenCache.set(info.sessionID, {
|
||||
providerID: info.providerID,
|
||||
modelID: info.modelID ?? "",
|
||||
tokens: info.tokens,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
type TextPart = {
|
||||
type?: string
|
||||
text?: string
|
||||
}
|
||||
|
||||
type MessageInfo = {
|
||||
id?: string
|
||||
role?: string
|
||||
error?: unknown
|
||||
model?: {
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
}
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
}
|
||||
|
||||
export type SessionMessage = {
|
||||
info?: MessageInfo
|
||||
parts?: TextPart[]
|
||||
}
|
||||
|
||||
export function getLastAssistantMessage(messages: SessionMessage[]): SessionMessage | null {
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
if (messages[index].info?.role === "assistant") {
|
||||
return messages[index]
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function extractAssistantText(message: SessionMessage): string {
|
||||
return (message.parts ?? [])
|
||||
.filter((part) => part.type === "text" && typeof part.text === "string")
|
||||
.map((part) => part.text?.trim() ?? "")
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
export function isGptAssistantMessage(message: SessionMessage): boolean {
|
||||
const modelID = message.info?.model?.modelID ?? message.info?.modelID
|
||||
return typeof modelID === "string" && modelID.toLowerCase().includes("gpt")
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export const HOOK_NAME = "gpt-permission-continuation"
|
||||
export const CONTINUATION_PROMPT = "continue"
|
||||
|
||||
export const DEFAULT_STALL_PATTERNS = [
|
||||
"if you want",
|
||||
"would you like",
|
||||
"shall i",
|
||||
"do you want me to",
|
||||
"let me know if",
|
||||
] as const
|
||||
@@ -0,0 +1,23 @@
|
||||
import { DEFAULT_STALL_PATTERNS } from "./constants"
|
||||
|
||||
function getTrailingSegment(text: string): string {
|
||||
const normalized = text.trim().replace(/\s+/g, " ")
|
||||
if (!normalized) return ""
|
||||
|
||||
const sentenceParts = normalized.split(/(?<=[.!?])\s+/)
|
||||
return sentenceParts[sentenceParts.length - 1]?.trim().toLowerCase() ?? ""
|
||||
}
|
||||
|
||||
export function detectStallPattern(
|
||||
text: string,
|
||||
patterns: readonly string[] = DEFAULT_STALL_PATTERNS,
|
||||
): boolean {
|
||||
if (!text.trim()) return false
|
||||
|
||||
const tail = text.slice(-800)
|
||||
const lines = tail.split("\n").map((line) => line.trim()).filter(Boolean)
|
||||
const hotZone = lines.slice(-3).join(" ")
|
||||
const trailingSegment = getTrailingSegment(hotZone)
|
||||
|
||||
return patterns.some((pattern) => trailingSegment.startsWith(pattern.toLowerCase()))
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { createGptPermissionContinuationHook } from "."
|
||||
|
||||
type SessionMessage = {
|
||||
info: {
|
||||
id: string
|
||||
role: "user" | "assistant"
|
||||
model?: {
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
}
|
||||
modelID?: string
|
||||
}
|
||||
parts?: Array<{ type: string; text?: string }>
|
||||
}
|
||||
|
||||
function createMockPluginInput(messages: SessionMessage[]) {
|
||||
const promptCalls: string[] = []
|
||||
|
||||
const ctx = {
|
||||
directory: "/tmp/test",
|
||||
client: {
|
||||
session: {
|
||||
messages: async () => ({ data: messages }),
|
||||
prompt: async (input: { body: { parts: Array<{ text: string }> } }) => {
|
||||
promptCalls.push(input.body.parts[0]?.text ?? "")
|
||||
return {}
|
||||
},
|
||||
promptAsync: async (input: { body: { parts: Array<{ text: string }> } }) => {
|
||||
promptCalls.push(input.body.parts[0]?.text ?? "")
|
||||
return {}
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
return { ctx, promptCalls }
|
||||
}
|
||||
|
||||
describe("gpt-permission-continuation", () => {
|
||||
test("injects continue when the last GPT assistant reply asks for permission", async () => {
|
||||
// given
|
||||
const { ctx, promptCalls } = createMockPluginInput([
|
||||
{
|
||||
info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" },
|
||||
parts: [{ type: "text", text: "I finished the analysis. If you want, I can apply the changes next." }],
|
||||
},
|
||||
])
|
||||
const hook = createGptPermissionContinuationHook(ctx)
|
||||
|
||||
// when
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
|
||||
// then
|
||||
expect(promptCalls).toEqual(["continue"])
|
||||
})
|
||||
|
||||
test("does not inject when the last assistant model is not GPT", async () => {
|
||||
// given
|
||||
const { ctx, promptCalls } = createMockPluginInput([
|
||||
{
|
||||
info: {
|
||||
id: "msg-1",
|
||||
role: "assistant",
|
||||
model: { providerID: "anthropic", modelID: "claude-sonnet-4" },
|
||||
},
|
||||
parts: [{ type: "text", text: "If you want, I can keep going." }],
|
||||
},
|
||||
])
|
||||
const hook = createGptPermissionContinuationHook(ctx)
|
||||
|
||||
// when
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
|
||||
// then
|
||||
expect(promptCalls).toEqual([])
|
||||
})
|
||||
|
||||
test("does not inject when the last assistant reply is not a stall pattern", async () => {
|
||||
// given
|
||||
const { ctx, promptCalls } = createMockPluginInput([
|
||||
{
|
||||
info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" },
|
||||
parts: [{ type: "text", text: "I completed the refactor and all tests pass." }],
|
||||
},
|
||||
])
|
||||
const hook = createGptPermissionContinuationHook(ctx)
|
||||
|
||||
// when
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
|
||||
// then
|
||||
expect(promptCalls).toEqual([])
|
||||
})
|
||||
|
||||
test("does not inject when a permission phrase appears before the final sentence", async () => {
|
||||
// given
|
||||
const { ctx, promptCalls } = createMockPluginInput([
|
||||
{
|
||||
info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" },
|
||||
parts: [{ type: "text", text: "If you want, I can keep going. The current work is complete." }],
|
||||
},
|
||||
])
|
||||
const hook = createGptPermissionContinuationHook(ctx)
|
||||
|
||||
// when
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
|
||||
// then
|
||||
expect(promptCalls).toEqual([])
|
||||
})
|
||||
|
||||
test("does not inject when continuation is stopped for the session", async () => {
|
||||
// given
|
||||
const { ctx, promptCalls } = createMockPluginInput([
|
||||
{
|
||||
info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" },
|
||||
parts: [{ type: "text", text: "If you want, I can continue with the fix." }],
|
||||
},
|
||||
])
|
||||
const hook = createGptPermissionContinuationHook(ctx, {
|
||||
isContinuationStopped: (sessionID) => sessionID === "ses-1",
|
||||
})
|
||||
|
||||
// when
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
|
||||
// then
|
||||
expect(promptCalls).toEqual([])
|
||||
})
|
||||
|
||||
test("does not inject twice for the same assistant message", async () => {
|
||||
// given
|
||||
const { ctx, promptCalls } = createMockPluginInput([
|
||||
{
|
||||
info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" },
|
||||
parts: [{ type: "text", text: "Would you like me to continue with the fix?" }],
|
||||
},
|
||||
])
|
||||
const hook = createGptPermissionContinuationHook(ctx)
|
||||
|
||||
// when
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
|
||||
// then
|
||||
expect(promptCalls).toEqual(["continue"])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
import { normalizeSDKResponse } from "../../shared"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
import {
|
||||
extractAssistantText,
|
||||
getLastAssistantMessage,
|
||||
isGptAssistantMessage,
|
||||
type SessionMessage,
|
||||
} from "./assistant-message"
|
||||
import { CONTINUATION_PROMPT, HOOK_NAME } from "./constants"
|
||||
import { detectStallPattern } from "./detector"
|
||||
import type { SessionStateStore } from "./session-state"
|
||||
|
||||
async function promptContinuation(
|
||||
ctx: PluginInput,
|
||||
sessionID: string,
|
||||
): Promise<void> {
|
||||
const payload = {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
parts: [{ type: "text" as const, text: CONTINUATION_PROMPT }],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
}
|
||||
|
||||
if (typeof ctx.client.session.promptAsync === "function") {
|
||||
await ctx.client.session.promptAsync(payload)
|
||||
return
|
||||
}
|
||||
|
||||
await ctx.client.session.prompt(payload)
|
||||
}
|
||||
|
||||
export function createGptPermissionContinuationHandler(args: {
|
||||
ctx: PluginInput
|
||||
sessionStateStore: SessionStateStore
|
||||
isContinuationStopped?: (sessionID: string) => boolean
|
||||
}): (input: { event: { type: string; properties?: unknown } }) => Promise<void> {
|
||||
const { ctx, sessionStateStore, isContinuationStopped } = args
|
||||
|
||||
return async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
|
||||
const properties = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionID = (properties?.info as { id?: string } | undefined)?.id
|
||||
if (sessionID) {
|
||||
sessionStateStore.cleanup(sessionID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type !== "session.idle") return
|
||||
|
||||
const sessionID = properties?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
if (isContinuationStopped?.(sessionID)) {
|
||||
log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const state = sessionStateStore.getState(sessionID)
|
||||
if (state.inFlight) {
|
||||
log(`[${HOOK_NAME}] Skipped: prompt already in flight`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const messagesResponse = await ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
const messages = normalizeSDKResponse(messagesResponse, [] as SessionMessage[], {
|
||||
preferResponseOnMissingData: true,
|
||||
})
|
||||
const lastAssistantMessage = getLastAssistantMessage(messages)
|
||||
if (!lastAssistantMessage) return
|
||||
|
||||
const messageID = lastAssistantMessage.info?.id
|
||||
if (messageID && state.lastHandledMessageID === messageID) {
|
||||
log(`[${HOOK_NAME}] Skipped: already handled assistant message`, { sessionID, messageID })
|
||||
return
|
||||
}
|
||||
|
||||
if (lastAssistantMessage.info?.error) {
|
||||
log(`[${HOOK_NAME}] Skipped: last assistant message has error`, { sessionID, messageID })
|
||||
return
|
||||
}
|
||||
|
||||
if (!isGptAssistantMessage(lastAssistantMessage)) {
|
||||
log(`[${HOOK_NAME}] Skipped: last assistant model is not GPT`, { sessionID, messageID })
|
||||
return
|
||||
}
|
||||
|
||||
const assistantText = extractAssistantText(lastAssistantMessage)
|
||||
if (!detectStallPattern(assistantText)) {
|
||||
return
|
||||
}
|
||||
|
||||
state.inFlight = true
|
||||
await promptContinuation(ctx, sessionID)
|
||||
state.lastHandledMessageID = messageID
|
||||
state.lastInjectedAt = Date.now()
|
||||
log(`[${HOOK_NAME}] Injected continuation prompt`, { sessionID, messageID })
|
||||
} catch (error) {
|
||||
log(`[${HOOK_NAME}] Failed to inject continuation prompt`, {
|
||||
sessionID,
|
||||
error: String(error),
|
||||
})
|
||||
} finally {
|
||||
state.inFlight = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
import { createGptPermissionContinuationHandler } from "./handler"
|
||||
import { createSessionStateStore } from "./session-state"
|
||||
|
||||
export type GptPermissionContinuationHook = {
|
||||
handler: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
|
||||
wasRecentlyInjected: (sessionID: string) => boolean
|
||||
}
|
||||
|
||||
export function createGptPermissionContinuationHook(
|
||||
ctx: PluginInput,
|
||||
options?: {
|
||||
isContinuationStopped?: (sessionID: string) => boolean
|
||||
},
|
||||
): GptPermissionContinuationHook {
|
||||
const sessionStateStore = createSessionStateStore()
|
||||
|
||||
return {
|
||||
handler: createGptPermissionContinuationHandler({
|
||||
ctx,
|
||||
sessionStateStore,
|
||||
isContinuationStopped: options?.isContinuationStopped,
|
||||
}),
|
||||
wasRecentlyInjected(sessionID: string): boolean {
|
||||
return sessionStateStore.wasRecentlyInjected(sessionID, 5_000)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
type SessionState = {
|
||||
inFlight: boolean
|
||||
lastHandledMessageID?: string
|
||||
lastInjectedAt?: number
|
||||
}
|
||||
|
||||
export type SessionStateStore = ReturnType<typeof createSessionStateStore>
|
||||
|
||||
export function createSessionStateStore() {
|
||||
const states = new Map<string, SessionState>()
|
||||
|
||||
const getState = (sessionID: string): SessionState => {
|
||||
const existing = states.get(sessionID)
|
||||
if (existing) return existing
|
||||
|
||||
const created: SessionState = {
|
||||
inFlight: false,
|
||||
}
|
||||
states.set(sessionID, created)
|
||||
return created
|
||||
}
|
||||
|
||||
return {
|
||||
getState,
|
||||
wasRecentlyInjected(sessionID: string, windowMs: number): boolean {
|
||||
const state = states.get(sessionID)
|
||||
if (!state?.lastInjectedAt) return false
|
||||
return Date.now() - state.lastInjectedAt <= windowMs
|
||||
},
|
||||
cleanup(sessionID: string): void {
|
||||
states.delete(sessionID)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { createTodoContinuationEnforcer } from "../todo-continuation-enforcer"
|
||||
import { createGptPermissionContinuationHook } from "."
|
||||
|
||||
describe("gpt-permission-continuation coordination", () => {
|
||||
test("injects only once when GPT permission continuation and todo continuation are both eligible", async () => {
|
||||
// given
|
||||
const promptCalls: string[] = []
|
||||
const toastCalls: string[] = []
|
||||
const sessionID = "ses-dual-continuation"
|
||||
const ctx = {
|
||||
directory: "/tmp/test",
|
||||
client: {
|
||||
session: {
|
||||
messages: async () => ({
|
||||
data: [
|
||||
{
|
||||
info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" },
|
||||
parts: [{ type: "text", text: "If you want, I can implement the fix next." }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
todo: async () => ({
|
||||
data: [{ id: "1", content: "Task 1", status: "pending", priority: "high" }],
|
||||
}),
|
||||
prompt: async (input: { body: { parts: Array<{ text: string }> } }) => {
|
||||
promptCalls.push(input.body.parts[0]?.text ?? "")
|
||||
return {}
|
||||
},
|
||||
promptAsync: async (input: { body: { parts: Array<{ text: string }> } }) => {
|
||||
promptCalls.push(input.body.parts[0]?.text ?? "")
|
||||
return {}
|
||||
},
|
||||
},
|
||||
tui: {
|
||||
showToast: async (input: { body: { title: string } }) => {
|
||||
toastCalls.push(input.body.title)
|
||||
return {}
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
const gptPermissionContinuation = createGptPermissionContinuationHook(ctx)
|
||||
const todoContinuationEnforcer = createTodoContinuationEnforcer(ctx, {
|
||||
shouldSkipContinuation: (id) => gptPermissionContinuation.wasRecentlyInjected(id),
|
||||
})
|
||||
|
||||
// when
|
||||
await gptPermissionContinuation.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
await todoContinuationEnforcer.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
// then
|
||||
expect(promptCalls).toEqual(["continue"])
|
||||
expect(toastCalls).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -30,6 +30,7 @@ export { createCategorySkillReminderHook } from "./category-skill-reminder";
|
||||
export { createRalphLoopHook, type RalphLoopHook } from "./ralph-loop";
|
||||
export { createNoSisyphusGptHook } from "./no-sisyphus-gpt";
|
||||
export { createNoHephaestusNonGptHook } from "./no-hephaestus-non-gpt";
|
||||
export { createGptPermissionContinuationHook, type GptPermissionContinuationHook } from "./gpt-permission-continuation"
|
||||
export { createAutoSlashCommandHook } from "./auto-slash-command";
|
||||
export { createEditErrorRecoveryHook } from "./edit-error-recovery";
|
||||
|
||||
|
||||
@@ -14,6 +14,14 @@ import {
|
||||
import type { ContextCollector } from "../../features/context-injector"
|
||||
|
||||
export function createKeywordDetectorHook(ctx: PluginInput, _collector?: ContextCollector) {
|
||||
function getRuntimeVariant(input: { variant?: string }, message: Record<string, unknown>): string | undefined {
|
||||
if (typeof message["variant"] === "string") {
|
||||
return message["variant"]
|
||||
}
|
||||
|
||||
return typeof input.variant === "string" ? input.variant : undefined
|
||||
}
|
||||
|
||||
return {
|
||||
"chat.message": async (
|
||||
input: {
|
||||
@@ -21,6 +29,7 @@ export function createKeywordDetectorHook(ctx: PluginInput, _collector?: Context
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
messageID?: string
|
||||
variant?: string
|
||||
},
|
||||
output: {
|
||||
message: Record<string, unknown>
|
||||
@@ -72,15 +81,21 @@ export function createKeywordDetectorHook(ctx: PluginInput, _collector?: Context
|
||||
|
||||
const hasUltrawork = detectedKeywords.some((k) => k.type === "ultrawork")
|
||||
if (hasUltrawork) {
|
||||
log(`[keyword-detector] Ultrawork mode activated`, { sessionID: input.sessionID })
|
||||
const runtimeVariant = getRuntimeVariant(input, output.message)
|
||||
const isRuntimeMax = runtimeVariant === "max"
|
||||
|
||||
output.message.variant = "max"
|
||||
log(`[keyword-detector] Ultrawork mode activated`, {
|
||||
sessionID: input.sessionID,
|
||||
runtimeVariant,
|
||||
})
|
||||
|
||||
ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Ultrawork Mode Activated",
|
||||
message: "Maximum precision engaged. All agents at your disposal.",
|
||||
message: isRuntimeMax
|
||||
? "Maximum precision engaged. All agents at your disposal."
|
||||
: "Runtime variant preserved. All agents at your disposal.",
|
||||
variant: "success" as const,
|
||||
duration: 3000,
|
||||
},
|
||||
|
||||
@@ -169,8 +169,8 @@ describe("keyword-detector session filtering", () => {
|
||||
output
|
||||
)
|
||||
|
||||
// then - ultrawork should still work (variant set to max)
|
||||
expect(output.message.variant).toBe("max")
|
||||
// then - ultrawork should still work without forcing a new variant
|
||||
expect(output.message.variant).toBeUndefined()
|
||||
expect(toastCalls).toContain("Ultrawork Mode Activated")
|
||||
})
|
||||
|
||||
@@ -214,12 +214,12 @@ describe("keyword-detector session filtering", () => {
|
||||
output
|
||||
)
|
||||
|
||||
// then - all keywords should work
|
||||
expect(output.message.variant).toBe("max")
|
||||
// then - all keywords should work without forcing a new variant
|
||||
expect(output.message.variant).toBeUndefined()
|
||||
expect(toastCalls).toContain("Ultrawork Mode Activated")
|
||||
})
|
||||
|
||||
test("should override existing variant when ultrawork keyword is used", async () => {
|
||||
test("should preserve existing runtime variant when ultrawork keyword is used", async () => {
|
||||
// given - main session set with pre-existing variant from TUI
|
||||
setMainSession("main-123")
|
||||
|
||||
@@ -236,8 +236,8 @@ describe("keyword-detector session filtering", () => {
|
||||
output
|
||||
)
|
||||
|
||||
// then - ultrawork should override TUI variant to max
|
||||
expect(output.message.variant).toBe("max")
|
||||
// then - ultrawork should preserve the already resolved runtime variant
|
||||
expect(output.message.variant).toBe("low")
|
||||
expect(toastCalls).toContain("Ultrawork Mode Activated")
|
||||
})
|
||||
})
|
||||
@@ -311,8 +311,8 @@ describe("keyword-detector word boundary", () => {
|
||||
output
|
||||
)
|
||||
|
||||
// then - ultrawork should be triggered
|
||||
expect(output.message.variant).toBe("max")
|
||||
// then - ultrawork should be triggered without forcing max
|
||||
expect(output.message.variant).toBeUndefined()
|
||||
expect(toastCalls).toContain("Ultrawork Mode Activated")
|
||||
})
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user