merge dev into continuation runtime retry
# Conflicts: # src/hooks/ralph-loop/non-abort-error-continuation.test.ts
This commit is contained in:
@@ -15,11 +15,11 @@ Agent factories following `createXXXAgent(model) → AgentConfig` pattern. Each
|
||||
|
||||
| Agent | Model | Temp | Mode | Fallback Chain | Purpose |
|
||||
|-------|-------|------|------|----------------|---------|
|
||||
| **Sisyphus** | claude-opus-4-7 max | 0.1 | all | k2p5 -> kimi-k2.5 -> gpt-5.5 medium -> glm-5 -> big-pickle | Main orchestrator, plans + delegates |
|
||||
| **Sisyphus** | claude-opus-4-7 max | 0.1 | all | k2p5 -> kimi-k2.6 -> gpt-5.5 medium -> glm-5 -> big-pickle | Main orchestrator, plans + delegates |
|
||||
| **Hephaestus** | gpt-5.5 medium | 0.1 | all | — | Autonomous deep worker |
|
||||
| **Oracle** | gpt-5.5 high | 0.1 | subagent | gemini-3.1-pro high -> claude-opus-4-7 max | Read-only consultation |
|
||||
| **Librarian** | gpt-5.4-mini-fast | 0.1 | subagent | minimax-m2.7-highspeed -> minimax-m2.7 -> claude-haiku-4-5 -> gpt-5.4-nano | External docs/code search |
|
||||
| **Explore** | gpt-5.4-mini-fast | 0.1 | subagent | minimax-m2.7-highspeed -> minimax-m2.7 -> claude-haiku-4-5 -> gpt-5.4-nano | Contextual grep |
|
||||
| **Librarian** | gpt-5.4-mini-fast | 0.1 | subagent | qwen3.5-plus -> minimax-m2.7-highspeed -> minimax-m2.7 -> claude-haiku-4-5 -> gpt-5.4-nano | External docs/code search |
|
||||
| **Explore** | gpt-5.4-mini-fast | 0.1 | subagent | qwen3.5-plus -> minimax-m2.7-highspeed -> minimax-m2.7 -> claude-haiku-4-5 -> gpt-5.4-nano | Contextual grep |
|
||||
| **Multimodal-Looker** | gpt-5.3-codex medium | 0.1 | subagent | k2p5 -> gemini-3-flash -> glm-4.6v -> gpt-5-nano | PDF/image analysis |
|
||||
| **Metis** | claude-opus-4-7 max | **0.3** | subagent | gpt-5.5 high -> gemini-3.1-pro high | Pre-planning consultant |
|
||||
| **Momus** | gpt-5.5 xhigh | 0.1 | subagent | claude-opus-4-7 max -> gemini-3.1-pro high | Plan reviewer |
|
||||
|
||||
@@ -196,7 +196,7 @@ export function buildNonClaudePlannerSection(model: string): string {
|
||||
Multi-step task? **ALWAYS consult Plan Agent first.** Do NOT start implementation without a plan.
|
||||
|
||||
- Single-file fix or trivial change → proceed directly
|
||||
- Anything else (2+ steps, unclear scope, architecture) → \`task(subagent_type="plan", ...)\` FIRST
|
||||
- Anything else (2+ steps, unclear scope, architecture) → \`task(subagent_type="prometheus", ...)\` FIRST
|
||||
- Use \`task_id\` to resume the same Plan Agent - ask follow-up questions aggressively
|
||||
- If ANY part of the task is ambiguous, ask Plan Agent before guessing
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ description: Developer reference for the Hephaestus autonomous deep worker agent
|
||||
|------|---------|
|
||||
| `agent.ts` | `createHephaestusAgent()` factory, model-variant routing |
|
||||
| `gpt.ts` | Base GPT prompt: discipline rules, delegation, verification |
|
||||
| `gpt-5-5.ts` | GPT-5.5-native prompt tuned for current Hephaestus routing |
|
||||
| `gpt-5-4.ts` | GPT-5.4-native prompt with XML-tagged blocks, entropy-reduced |
|
||||
| `gpt-5-3-codex.ts` | GPT-5.3 Codex variant with task discipline sections |
|
||||
| `index.ts` | Barrel exports |
|
||||
|
||||
@@ -9,7 +9,7 @@ description: Developer reference for Sisyphus orchestrator model-specific prompt
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
4 files. Model-specific prompt variants for the Sisyphus main orchestrator. Parent `sisyphus.ts` routes to the correct variant based on active model.
|
||||
5 prompt/export files. Model-specific prompt variants for the Sisyphus main orchestrator. Parent `sisyphus.ts` routes to the correct variant based on active model.
|
||||
|
||||
## FILES
|
||||
|
||||
@@ -18,12 +18,14 @@ description: Developer reference for Sisyphus orchestrator model-specific prompt
|
||||
| `default.ts` | Base/Claude variant: task management, delegation guides, 542 LOC |
|
||||
| `gemini.ts` | Gemini-optimized: stricter tool-usage rules, 5 NEVER rules |
|
||||
| `gpt-5-4.ts` | GPT-5.4-native: 8-block architecture, entropy-reduced, 449 LOC |
|
||||
| `gpt-5-5.ts` | GPT-5.5-native: updated orchestration prompt tuned for GPT-5.5 |
|
||||
| `index.ts` | Barrel exports |
|
||||
|
||||
## VARIANT SELECTION
|
||||
|
||||
Parent `sisyphus.ts` selects variant by model name:
|
||||
- Contains "gemini" -> `gemini.ts`
|
||||
- Contains "gpt-5.5" -> `gpt-5-5.ts`
|
||||
- Contains "gpt-5.4" -> `gpt-5-4.ts`
|
||||
- Default -> `default.ts` (Claude, Kimi, GLM, etc.)
|
||||
|
||||
|
||||
@@ -287,7 +287,7 @@ Every implementation task follows this cycle. No exceptions.
|
||||
Follow \`<explore>\` protocol for tool usage and agent prompts.
|
||||
|
||||
2. PLAN - List files to modify, specific changes, dependencies, complexity estimate.
|
||||
Multi-step (2+) → consult Plan Agent via \`task(subagent_type="plan", ...)\`.
|
||||
Multi-step (2+) → consult Plan Agent via \`task(subagent_type="prometheus", ...)\`.
|
||||
Single-step → mental plan is sufficient.
|
||||
|
||||
<dependency_checks>
|
||||
|
||||
+23
-23
@@ -60,14 +60,14 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
const providerModelsSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue(null)
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(new Set())
|
||||
const overrides = {
|
||||
sisyphus: { model: "github-copilot/gpt-5.4" },
|
||||
sisyphus: { model: "github-copilot/gpt-5.5" },
|
||||
}
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], undefined, undefined)
|
||||
|
||||
// #then
|
||||
expect(agents.sisyphus.model).toBe("github-copilot/gpt-5.4")
|
||||
expect(agents.sisyphus.model).toBe("github-copilot/gpt-5.5")
|
||||
expect(agents.sisyphus.reasoningEffort).toBe("medium")
|
||||
expect(agents.sisyphus.thinking).toBeUndefined()
|
||||
providerModelsSpy.mockRestore()
|
||||
@@ -77,9 +77,9 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
test("Atlas uses uiSelectedModel", async () => {
|
||||
// #given
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set(["openai/gpt-5.4", "anthropic/claude-sonnet-4-6"])
|
||||
new Set(["openai/gpt-5.5", "anthropic/claude-sonnet-4-6"])
|
||||
)
|
||||
const uiSelectedModel = "openai/gpt-5.4"
|
||||
const uiSelectedModel = "openai/gpt-5.5"
|
||||
|
||||
try {
|
||||
// #when
|
||||
@@ -98,7 +98,7 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
|
||||
// #then
|
||||
expect(agents.atlas).toBeDefined()
|
||||
expect(agents.atlas.model).toBe("openai/gpt-5.4")
|
||||
expect(agents.atlas.model).toBe("openai/gpt-5.5")
|
||||
} finally {
|
||||
fetchSpy.mockRestore()
|
||||
}
|
||||
@@ -107,9 +107,9 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
test("user config model takes priority over uiSelectedModel for sisyphus", async () => {
|
||||
// #given
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set(["openai/gpt-5.4", "anthropic/claude-sonnet-4-6"])
|
||||
new Set(["openai/gpt-5.5", "anthropic/claude-sonnet-4-6"])
|
||||
)
|
||||
const uiSelectedModel = "openai/gpt-5.4"
|
||||
const uiSelectedModel = "openai/gpt-5.5"
|
||||
const overrides = {
|
||||
sisyphus: { model: "google/antigravity-claude-opus-4-5-thinking" },
|
||||
}
|
||||
@@ -140,9 +140,9 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
test("user config model takes priority over uiSelectedModel for atlas", async () => {
|
||||
// #given
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set(["openai/gpt-5.4", "anthropic/claude-sonnet-4-6"])
|
||||
new Set(["openai/gpt-5.5", "anthropic/claude-sonnet-4-6"])
|
||||
)
|
||||
const uiSelectedModel = "openai/gpt-5.4"
|
||||
const uiSelectedModel = "openai/gpt-5.5"
|
||||
const overrides = {
|
||||
atlas: { model: "google/antigravity-claude-opus-4-5-thinking" },
|
||||
}
|
||||
@@ -265,14 +265,14 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
const providerModelsSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue(null)
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(new Set())
|
||||
const overrides = {
|
||||
sisyphus: { model: "github-copilot/gpt-5.4", temperature: 0.5 },
|
||||
sisyphus: { model: "github-copilot/gpt-5.5", temperature: 0.5 },
|
||||
}
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], undefined, undefined)
|
||||
|
||||
// #then
|
||||
expect(agents.sisyphus.model).toBe("github-copilot/gpt-5.4")
|
||||
expect(agents.sisyphus.model).toBe("github-copilot/gpt-5.5")
|
||||
expect(agents.sisyphus.temperature).toBe(0.5)
|
||||
providerModelsSpy.mockRestore()
|
||||
fetchSpy.mockRestore()
|
||||
@@ -306,7 +306,7 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
"opencode/kimi-k2.5-free",
|
||||
"zai-coding-plan/glm-5",
|
||||
"opencode/big-pickle",
|
||||
"openai/gpt-5.4",
|
||||
"openai/gpt-5.5",
|
||||
])
|
||||
)
|
||||
|
||||
@@ -343,7 +343,7 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
test("excludes hidden custom agents from orchestrator prompts", async () => {
|
||||
// #given
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.4"])
|
||||
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.5"])
|
||||
)
|
||||
|
||||
const customAgentSummaries = [
|
||||
@@ -379,7 +379,7 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
test("excludes disabled custom agents from orchestrator prompts", async () => {
|
||||
// #given
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.4"])
|
||||
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.5"])
|
||||
)
|
||||
|
||||
const customAgentSummaries = [
|
||||
@@ -415,7 +415,7 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
test("excludes custom agents when disabledAgents contains their name (case-insensitive)", async () => {
|
||||
// #given
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.4"])
|
||||
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.5"])
|
||||
)
|
||||
|
||||
const disabledAgents = ["ReSeArChEr"]
|
||||
@@ -451,7 +451,7 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
test("does not advertise duplicate custom agents case-insensitively", async () => {
|
||||
// #given
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.4"])
|
||||
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.5"])
|
||||
)
|
||||
|
||||
const customAgentSummaries = [
|
||||
@@ -483,7 +483,7 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
test("does not surface custom agent strings in orchestrator prompts", async () => {
|
||||
// #given
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.4"])
|
||||
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.5"])
|
||||
)
|
||||
|
||||
const customAgentSummaries = [
|
||||
@@ -842,7 +842,7 @@ describe("Atlas is unaffected by environment context toggle", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.4"])
|
||||
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.5"])
|
||||
)
|
||||
})
|
||||
|
||||
@@ -968,7 +968,7 @@ describe("createBuiltinAgents with requiresAnyModel gating (sisyphus)", () => {
|
||||
// #given - user configures a model from a plugin provider (like antigravity)
|
||||
// that is NOT in the availableModels cache and NOT in the fallback chain
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set(["openai/gpt-5.4"])
|
||||
new Set(["openai/gpt-5.5"])
|
||||
)
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(
|
||||
["openai"]
|
||||
@@ -1098,7 +1098,7 @@ describe("buildAgent with category and skills", () => {
|
||||
|
||||
const categories = {
|
||||
"custom-category": {
|
||||
model: "openai/gpt-5.4",
|
||||
model: "openai/gpt-5.5",
|
||||
variant: "xhigh",
|
||||
},
|
||||
}
|
||||
@@ -1107,7 +1107,7 @@ describe("buildAgent with category and skills", () => {
|
||||
const agent = buildAgent(source["test-agent"], TEST_MODEL, categories)
|
||||
|
||||
// #then
|
||||
expect(agent.model).toBe("openai/gpt-5.4")
|
||||
expect(agent.model).toBe("openai/gpt-5.5")
|
||||
expect(agent.variant).toBe("xhigh")
|
||||
})
|
||||
|
||||
@@ -1357,7 +1357,7 @@ describe("override.category expansion in createBuiltinAgents", () => {
|
||||
// #given - custom category has reasoningEffort=xhigh, direct override says "low"
|
||||
const categories = {
|
||||
"test-cat": {
|
||||
model: "openai/gpt-5.4",
|
||||
model: "openai/gpt-5.5",
|
||||
reasoningEffort: "xhigh" as const,
|
||||
},
|
||||
}
|
||||
@@ -1377,7 +1377,7 @@ describe("override.category expansion in createBuiltinAgents", () => {
|
||||
// #given - custom category has reasoningEffort, no direct reasoningEffort in override
|
||||
const categories = {
|
||||
"reasoning-cat": {
|
||||
model: "openai/gpt-5.4",
|
||||
model: "openai/gpt-5.5",
|
||||
reasoningEffort: "high" as const,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -4138,7 +4138,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
|
||||
"atlas": {
|
||||
"fallback_models": [
|
||||
{
|
||||
"model": "vercel/moonshotai/kimi-k2.5",
|
||||
"model": "vercel/moonshotai/kimi-k2.6",
|
||||
},
|
||||
{
|
||||
"model": "vercel/openai/gpt-5.5",
|
||||
@@ -4189,7 +4189,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
|
||||
"variant": "high",
|
||||
},
|
||||
{
|
||||
"model": "vercel/zai/glm-5",
|
||||
"model": "vercel/zai/glm-5.1",
|
||||
},
|
||||
],
|
||||
"model": "vercel/anthropic/claude-opus-4.7",
|
||||
@@ -4206,7 +4206,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
|
||||
"variant": "high",
|
||||
},
|
||||
{
|
||||
"model": "vercel/zai/glm-5",
|
||||
"model": "vercel/zai/glm-5.1",
|
||||
},
|
||||
],
|
||||
"model": "vercel/openai/gpt-5.5",
|
||||
@@ -4215,7 +4215,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
|
||||
"multimodal-looker": {
|
||||
"fallback_models": [
|
||||
{
|
||||
"model": "vercel/moonshotai/kimi-k2.5",
|
||||
"model": "vercel/moonshotai/kimi-k2.6",
|
||||
},
|
||||
{
|
||||
"model": "vercel/zai/glm-4.6v",
|
||||
@@ -4238,7 +4238,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
|
||||
"variant": "max",
|
||||
},
|
||||
{
|
||||
"model": "vercel/zai/glm-5",
|
||||
"model": "vercel/zai/glm-5.1",
|
||||
},
|
||||
],
|
||||
"model": "vercel/openai/gpt-5.5",
|
||||
@@ -4251,7 +4251,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
|
||||
"variant": "high",
|
||||
},
|
||||
{
|
||||
"model": "vercel/zai/glm-5",
|
||||
"model": "vercel/zai/glm-5.1",
|
||||
},
|
||||
{
|
||||
"model": "vercel/google/gemini-3.1-pro-preview",
|
||||
@@ -4262,6 +4262,9 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
|
||||
},
|
||||
"sisyphus": {
|
||||
"fallback_models": [
|
||||
{
|
||||
"model": "vercel/moonshotai/kimi-k2.6",
|
||||
},
|
||||
{
|
||||
"model": "vercel/moonshotai/kimi-k2.5",
|
||||
},
|
||||
@@ -4279,7 +4282,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
|
||||
"sisyphus-junior": {
|
||||
"fallback_models": [
|
||||
{
|
||||
"model": "vercel/moonshotai/kimi-k2.5",
|
||||
"model": "vercel/moonshotai/kimi-k2.6",
|
||||
},
|
||||
{
|
||||
"model": "vercel/openai/gpt-5.5",
|
||||
@@ -4348,7 +4351,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
|
||||
"variant": "max",
|
||||
},
|
||||
{
|
||||
"model": "vercel/zai/glm-5",
|
||||
"model": "vercel/zai/glm-5.1",
|
||||
},
|
||||
],
|
||||
"model": "vercel/openai/gpt-5.5",
|
||||
@@ -4361,7 +4364,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
|
||||
"variant": "medium",
|
||||
},
|
||||
{
|
||||
"model": "vercel/moonshotai/kimi-k2.5",
|
||||
"model": "vercel/moonshotai/kimi-k2.6",
|
||||
},
|
||||
{
|
||||
"model": "vercel/google/gemini-3-flash",
|
||||
@@ -4379,7 +4382,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
|
||||
"variant": "medium",
|
||||
},
|
||||
{
|
||||
"model": "vercel/moonshotai/kimi-k2.5",
|
||||
"model": "vercel/moonshotai/kimi-k2.6",
|
||||
},
|
||||
{
|
||||
"model": "vercel/google/gemini-3-flash",
|
||||
@@ -4399,6 +4402,9 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
|
||||
"model": "vercel/anthropic/claude-opus-4.7",
|
||||
"variant": "max",
|
||||
},
|
||||
{
|
||||
"model": "vercel/zai/glm-5.1",
|
||||
},
|
||||
],
|
||||
"model": "vercel/google/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
@@ -4406,7 +4412,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
|
||||
"writing": {
|
||||
"fallback_models": [
|
||||
{
|
||||
"model": "vercel/moonshotai/kimi-k2.5",
|
||||
"model": "vercel/moonshotai/kimi-k2.6",
|
||||
},
|
||||
{
|
||||
"model": "vercel/anthropic/claude-sonnet-4.6",
|
||||
@@ -4428,7 +4434,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
|
||||
"atlas": {
|
||||
"fallback_models": [
|
||||
{
|
||||
"model": "vercel/moonshotai/kimi-k2.5",
|
||||
"model": "vercel/moonshotai/kimi-k2.6",
|
||||
},
|
||||
{
|
||||
"model": "vercel/openai/gpt-5.5",
|
||||
@@ -4479,7 +4485,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
|
||||
"variant": "high",
|
||||
},
|
||||
{
|
||||
"model": "vercel/zai/glm-5",
|
||||
"model": "vercel/zai/glm-5.1",
|
||||
},
|
||||
],
|
||||
"model": "vercel/anthropic/claude-opus-4.7",
|
||||
@@ -4496,7 +4502,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
|
||||
"variant": "high",
|
||||
},
|
||||
{
|
||||
"model": "vercel/zai/glm-5",
|
||||
"model": "vercel/zai/glm-5.1",
|
||||
},
|
||||
],
|
||||
"model": "vercel/openai/gpt-5.5",
|
||||
@@ -4505,7 +4511,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
|
||||
"multimodal-looker": {
|
||||
"fallback_models": [
|
||||
{
|
||||
"model": "vercel/moonshotai/kimi-k2.5",
|
||||
"model": "vercel/moonshotai/kimi-k2.6",
|
||||
},
|
||||
{
|
||||
"model": "vercel/zai/glm-4.6v",
|
||||
@@ -4528,7 +4534,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
|
||||
"variant": "max",
|
||||
},
|
||||
{
|
||||
"model": "vercel/zai/glm-5",
|
||||
"model": "vercel/zai/glm-5.1",
|
||||
},
|
||||
],
|
||||
"model": "vercel/openai/gpt-5.5",
|
||||
@@ -4541,7 +4547,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
|
||||
"variant": "high",
|
||||
},
|
||||
{
|
||||
"model": "vercel/zai/glm-5",
|
||||
"model": "vercel/zai/glm-5.1",
|
||||
},
|
||||
{
|
||||
"model": "vercel/google/gemini-3.1-pro-preview",
|
||||
@@ -4552,6 +4558,9 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
|
||||
},
|
||||
"sisyphus": {
|
||||
"fallback_models": [
|
||||
{
|
||||
"model": "vercel/moonshotai/kimi-k2.6",
|
||||
},
|
||||
{
|
||||
"model": "vercel/moonshotai/kimi-k2.5",
|
||||
},
|
||||
@@ -4569,7 +4578,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
|
||||
"sisyphus-junior": {
|
||||
"fallback_models": [
|
||||
{
|
||||
"model": "vercel/moonshotai/kimi-k2.5",
|
||||
"model": "vercel/moonshotai/kimi-k2.6",
|
||||
},
|
||||
{
|
||||
"model": "vercel/openai/gpt-5.5",
|
||||
@@ -4638,7 +4647,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
|
||||
"variant": "max",
|
||||
},
|
||||
{
|
||||
"model": "vercel/zai/glm-5",
|
||||
"model": "vercel/zai/glm-5.1",
|
||||
},
|
||||
],
|
||||
"model": "vercel/openai/gpt-5.5",
|
||||
@@ -4653,6 +4662,9 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
|
||||
{
|
||||
"model": "vercel/zai/glm-5",
|
||||
},
|
||||
{
|
||||
"model": "vercel/zai/glm-5.1",
|
||||
},
|
||||
{
|
||||
"model": "vercel/moonshotai/kimi-k2.5",
|
||||
},
|
||||
@@ -4667,7 +4679,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
|
||||
"variant": "medium",
|
||||
},
|
||||
{
|
||||
"model": "vercel/moonshotai/kimi-k2.5",
|
||||
"model": "vercel/moonshotai/kimi-k2.6",
|
||||
},
|
||||
{
|
||||
"model": "vercel/google/gemini-3-flash",
|
||||
@@ -4687,6 +4699,9 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
|
||||
"model": "vercel/anthropic/claude-opus-4.7",
|
||||
"variant": "max",
|
||||
},
|
||||
{
|
||||
"model": "vercel/zai/glm-5.1",
|
||||
},
|
||||
],
|
||||
"model": "vercel/google/gemini-3.1-pro-preview",
|
||||
"variant": "high",
|
||||
@@ -4694,7 +4709,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
|
||||
"writing": {
|
||||
"fallback_models": [
|
||||
{
|
||||
"model": "vercel/moonshotai/kimi-k2.5",
|
||||
"model": "vercel/moonshotai/kimi-k2.6",
|
||||
},
|
||||
{
|
||||
"model": "vercel/anthropic/claude-sonnet-4.6",
|
||||
|
||||
@@ -54,7 +54,7 @@ describe("detectCurrentConfig - single package detection", () => {
|
||||
it("detects OpenCode Go from the existing omo config", () => {
|
||||
// given
|
||||
writeFileSync(testConfigPath, JSON.stringify({ plugin: ["oh-my-opencode"] }, null, 2) + "\n", "utf-8")
|
||||
writeFileSync(testOmoConfigPath, JSON.stringify({ agents: { atlas: { model: "opencode-go/kimi-k2.5" } } }, null, 2) + "\n", "utf-8")
|
||||
writeFileSync(testOmoConfigPath, JSON.stringify({ agents: { atlas: { model: "opencode-go/kimi-k2.6" } } }, null, 2) + "\n", "utf-8")
|
||||
|
||||
// when
|
||||
const result = detectCurrentConfig()
|
||||
|
||||
@@ -31,13 +31,13 @@ describe("model-resolution-config", () => {
|
||||
process.env.OPENCODE_CONFIG_DIR = testConfigDir
|
||||
writeFileSync(
|
||||
join(testConfigDir, "oh-my-openagent.json"),
|
||||
JSON.stringify({ agents: { atlas: { model: "opencode-go/kimi-k2.5" } } }, null, 2) + "\n",
|
||||
JSON.stringify({ agents: { atlas: { model: "opencode-go/kimi-k2.6" } } }, null, 2) + "\n",
|
||||
"utf-8",
|
||||
)
|
||||
|
||||
const config = loadOmoConfig()
|
||||
|
||||
expect(config?.agents?.atlas?.model).toBe("opencode-go/kimi-k2.5")
|
||||
expect(config?.agents?.atlas?.model).toBe("opencode-go/kimi-k2.6")
|
||||
} finally {
|
||||
rmSync(testConfigDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { afterEach, describe, expect, it, mock } from "bun:test"
|
||||
|
||||
const originalWhich = Bun.which
|
||||
|
||||
afterEach(() => {
|
||||
Bun.which = originalWhich
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
describe("getGhCliInfo", () => {
|
||||
it("falls back to gh --version when Bun.which cannot find gh", async () => {
|
||||
// given
|
||||
Bun.which = mock(() => null)
|
||||
mock.module("../spawn-with-timeout", () => ({
|
||||
spawnWithTimeout: mock((command: string[]) => {
|
||||
if (command.join(" ") === "gh --version") {
|
||||
return Promise.resolve({ stdout: "gh version 2.82.1\n", stderr: "", exitCode: 0, timedOut: false })
|
||||
}
|
||||
|
||||
return Promise.resolve({ stdout: "", stderr: "not logged in", exitCode: 1, timedOut: false })
|
||||
}),
|
||||
}))
|
||||
const { getGhCliInfo } = await import("./tools-gh")
|
||||
|
||||
// when
|
||||
const info = await getGhCliInfo()
|
||||
|
||||
// then
|
||||
expect(info.installed).toBe(true)
|
||||
expect(info.version).toBe("2.82.1")
|
||||
expect(info.path).toBe(null)
|
||||
})
|
||||
})
|
||||
@@ -80,6 +80,20 @@ async function getGhAuthStatus(): Promise<{
|
||||
export async function getGhCliInfo(): Promise<GhCliInfo> {
|
||||
const binaryStatus = await checkBinaryExists("gh")
|
||||
if (!binaryStatus.exists) {
|
||||
const version = await getGhVersion()
|
||||
if (version) {
|
||||
const authStatus = await getGhAuthStatus()
|
||||
return {
|
||||
installed: true,
|
||||
version,
|
||||
path: null,
|
||||
authenticated: authStatus.authenticated,
|
||||
username: authStatus.username,
|
||||
scopes: authStatus.scopes,
|
||||
error: authStatus.error,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
installed: false,
|
||||
version: null,
|
||||
|
||||
+11
-2
@@ -1,9 +1,18 @@
|
||||
import type { DoctorOptions } from "./types"
|
||||
import { runDoctor } from "./runner"
|
||||
import { EXIT_CODES } from "./constants"
|
||||
|
||||
export async function doctor(options: DoctorOptions = { mode: "default" }): Promise<number> {
|
||||
const result = await runDoctor(options)
|
||||
return result.exitCode
|
||||
try {
|
||||
const result = await runDoctor(options)
|
||||
return result.exitCode
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error("\nDoctor failed unexpectedly:", message)
|
||||
console.error("This may indicate memory pressure (OOM/SIGKILL) or a corrupted installation.")
|
||||
console.error("Try: OMO_DISABLE_POSTHOG=1 bunx oh-my-opencode doctor --verbose\n")
|
||||
return EXIT_CODES.FAILURE
|
||||
}
|
||||
}
|
||||
|
||||
export * from "./types"
|
||||
|
||||
@@ -130,7 +130,7 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
|
||||
if (avail.native.openai) {
|
||||
agentConfig = { model: "openai/gpt-5.4-mini-fast" }
|
||||
} else if (avail.opencodeGo) {
|
||||
agentConfig = { model: "opencode-go/minimax-m2.7" }
|
||||
agentConfig = { model: "opencode-go/qwen3.5-plus" }
|
||||
} else if (avail.zai) {
|
||||
agentConfig = { model: ZAI_MODEL }
|
||||
} else if (avail.vercelAiGateway) {
|
||||
@@ -151,7 +151,7 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
|
||||
} else if (avail.opencodeZen) {
|
||||
agentConfig = { model: "opencode/claude-haiku-4-5" }
|
||||
} else if (avail.opencodeGo) {
|
||||
agentConfig = { model: "opencode-go/minimax-m2.7" }
|
||||
agentConfig = { model: "opencode-go/qwen3.5-plus" }
|
||||
} else if (avail.copilot) {
|
||||
agentConfig = { model: "github-copilot/gpt-5-mini" }
|
||||
} else if (avail.vercelAiGateway) {
|
||||
|
||||
@@ -105,6 +105,41 @@ describe("checkCompletionConditions continuation coverage", () => {
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("returns true when the mirrored worktree plan is complete even if the main repo plan is stale", async () => {
|
||||
// given
|
||||
spyOn(console, "log").mockImplementation(() => {})
|
||||
const directory = createTempDir()
|
||||
const mainPlanPath = join(directory, ".sisyphus", "plans", "done-in-worktree-plan.md")
|
||||
const worktreeDirectory = createTempDir()
|
||||
const worktreePlanPath = join(worktreeDirectory, ".sisyphus", "plans", "done-in-worktree-plan.md")
|
||||
mkdirSync(join(directory, ".sisyphus", "plans"), { recursive: true })
|
||||
mkdirSync(join(worktreeDirectory, ".sisyphus", "plans"), { recursive: true })
|
||||
writeFileSync(mainPlanPath, "- [ ] stale main repo task\n", "utf-8")
|
||||
writeFileSync(worktreePlanPath, "- [x] completed worktree task\n", "utf-8")
|
||||
const sisyphusDir = join(directory, ".sisyphus")
|
||||
mkdirSync(sisyphusDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(sisyphusDir, "boulder.json"),
|
||||
JSON.stringify({
|
||||
active_plan: mainPlanPath,
|
||||
started_at: new Date().toISOString(),
|
||||
session_ids: ["test-session"],
|
||||
plan_name: "done-in-worktree-plan",
|
||||
agent: "atlas",
|
||||
worktree_path: worktreeDirectory,
|
||||
}),
|
||||
"utf-8",
|
||||
)
|
||||
const ctx = createMockContext(directory)
|
||||
const { checkCompletionConditions } = await import("./completion")
|
||||
|
||||
// when
|
||||
const result = await checkCompletionConditions(ctx)
|
||||
|
||||
// then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false when current session is an appended descendant of an active boulder session with unchecked plan items", async () => {
|
||||
// given
|
||||
spyOn(console, "log").mockImplementation(() => {})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getPlanProgress, readBoulderState } from "../../features/boulder-state"
|
||||
import { getPlanProgress, readBoulderState, resolveBoulderPlanPath } from "../../features/boulder-state"
|
||||
import { getSessionAgent } from "../../features/claude-code-session-state"
|
||||
import {
|
||||
getActiveContinuationMarkerReason,
|
||||
@@ -47,7 +47,7 @@ async function hasActiveBoulderContinuation(
|
||||
const boulder = readBoulderState(directory)
|
||||
if (!boulder) return false
|
||||
|
||||
const progress = getPlanProgress(boulder.active_plan)
|
||||
const progress = getPlanProgress(resolveBoulderPlanPath(directory, boulder))
|
||||
if (progress.isComplete) return false
|
||||
if (!client) return false
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ export const AgentOverrideConfigSchema = z.object({
|
||||
})
|
||||
.optional(),
|
||||
/** Reasoning effort level (OpenAI). Overrides category and default settings. */
|
||||
reasoningEffort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh"]).optional(),
|
||||
reasoningEffort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh", "max"]).optional(),
|
||||
/** Text verbosity level. */
|
||||
textVerbosity: z.enum(["low", "medium", "high"]).optional(),
|
||||
/** Provider-specific options. Passed directly to OpenCode SDK. */
|
||||
|
||||
@@ -16,7 +16,7 @@ export const CategoryConfigSchema = z.object({
|
||||
budgetTokens: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
reasoningEffort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh"]).optional(),
|
||||
reasoningEffort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh", "max"]).optional(),
|
||||
textVerbosity: z.enum(["low", "medium", "high"]).optional(),
|
||||
tools: z.record(z.string(), z.boolean()).optional(),
|
||||
prompt_append: z.string().optional(),
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from "zod"
|
||||
export const FallbackModelObjectSchema = z.object({
|
||||
model: z.string(),
|
||||
variant: z.string().optional(),
|
||||
reasoningEffort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh"]).optional(),
|
||||
reasoningEffort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh", "max"]).optional(),
|
||||
temperature: z.number().min(0).max(2).optional(),
|
||||
top_p: z.number().min(0).max(1).optional(),
|
||||
maxTokens: z.number().optional(),
|
||||
|
||||
@@ -23,6 +23,12 @@ mock.module("../../shared/connected-providers-cache", () => ({
|
||||
writeProviderModelsCache: () => {},
|
||||
updateConnectedProvidersCache: () => {},
|
||||
}))
|
||||
mock.module("../../shared/frontmatter", () => ({
|
||||
parseFrontmatter: () => ({ frontmatter: {}, content: "" }),
|
||||
}))
|
||||
mock.module("js-yaml", () => ({
|
||||
load: () => ({}),
|
||||
}))
|
||||
mock.restore()
|
||||
|
||||
|
||||
@@ -2447,6 +2453,69 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
expect(task.sessionId).toBeUndefined()
|
||||
})
|
||||
|
||||
test("should sanitize wrapped agent names before task creation and queueing", async () => {
|
||||
// given
|
||||
const input = {
|
||||
description: "Test task",
|
||||
prompt: "Do something",
|
||||
agent: "\\hephaestus\\",
|
||||
parentSessionId: "parent-session",
|
||||
parentMessageId: "parent-message",
|
||||
}
|
||||
|
||||
// when
|
||||
const task = await manager.launch(input)
|
||||
const queueItem = getQueuesByKey(manager).values().next().value?.[0]
|
||||
|
||||
// then
|
||||
expect(task.agent).toBe("hephaestus")
|
||||
expect(getTaskMap(manager).get(task.id)?.agent).toBe("hephaestus")
|
||||
// queueItem may be undefined if the queue was immediately processed
|
||||
if (queueItem) {
|
||||
expect(queueItem.input.agent).toBe("hephaestus")
|
||||
}
|
||||
})
|
||||
|
||||
test("should sanitize slash and quote wrapped agent names before task creation and queueing", async () => {
|
||||
// given
|
||||
const input = {
|
||||
description: "Test task",
|
||||
prompt: "Do something",
|
||||
agent: "\"/hephaestus/\"",
|
||||
parentSessionId: "parent-session",
|
||||
parentMessageId: "parent-message",
|
||||
}
|
||||
|
||||
// when
|
||||
const task = await manager.launch(input)
|
||||
const queueItem = getQueuesByKey(manager).values().next().value?.[0]
|
||||
|
||||
// then
|
||||
expect(task.agent).toBe("hephaestus")
|
||||
expect(getTaskMap(manager).get(task.id)?.agent).toBe("hephaestus")
|
||||
// queueItem may be undefined if the queue was immediately processed
|
||||
if (queueItem) {
|
||||
expect(queueItem.input.agent).toBe("hephaestus")
|
||||
}
|
||||
})
|
||||
|
||||
test("should reject wrapper-only agent names after sanitization", async () => {
|
||||
// given
|
||||
const input = {
|
||||
description: "Test task",
|
||||
prompt: "Do something",
|
||||
agent: "\\\"/'\\\"/",
|
||||
parentSessionId: "parent-session",
|
||||
parentMessageId: "parent-message",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = manager.launch(input)
|
||||
|
||||
// then
|
||||
await expect(result).rejects.toThrow("Agent parameter is required after sanitization")
|
||||
})
|
||||
|
||||
test("should initialize attempt state for a newly launched task", async () => {
|
||||
// given
|
||||
const input = {
|
||||
|
||||
@@ -383,6 +383,12 @@ export class BackgroundManager {
|
||||
throw new Error("Agent parameter is required")
|
||||
}
|
||||
|
||||
input = { ...input, agent: input.agent.trim().replace(/^[\\/"']+|[\\/"']+$/g, "").trim() }
|
||||
|
||||
if (!input.agent) {
|
||||
throw new Error("Agent parameter is required after sanitization")
|
||||
}
|
||||
|
||||
const spawnReservation = await this.reserveSubagentSpawn(input.parentSessionId)
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { dirname, join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
import {
|
||||
readBoulderState,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
createBoulderState,
|
||||
findPrometheusPlans,
|
||||
getTaskSessionState,
|
||||
resolveBoulderPlanPath,
|
||||
upsertTaskSessionState,
|
||||
} from "./storage"
|
||||
import type { BoulderState } from "./types"
|
||||
@@ -778,4 +779,46 @@ describe("boulder-state", () => {
|
||||
expect(state.agent).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveBoulderPlanPath", () => {
|
||||
test("should prefer the mirrored worktree plan when it exists", () => {
|
||||
// given
|
||||
const planPath = join(TEST_DIR, ".sisyphus", "plans", "worktree-plan.md")
|
||||
const worktreeDir = join(tmpdir(), `boulder-state-worktree-${Date.now()}`)
|
||||
const worktreePlanPath = join(worktreeDir, ".sisyphus", "plans", "worktree-plan.md")
|
||||
mkdirSync(dirname(planPath), { recursive: true })
|
||||
mkdirSync(dirname(worktreePlanPath), { recursive: true })
|
||||
writeFileSync(planPath, "# Plan\n- [ ] Main repo task\n")
|
||||
writeFileSync(worktreePlanPath, "# Plan\n- [x] Worktree task\n")
|
||||
|
||||
try {
|
||||
// when
|
||||
const resolvedPath = resolveBoulderPlanPath(TEST_DIR, {
|
||||
active_plan: planPath,
|
||||
worktree_path: worktreeDir,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(resolvedPath).toBe(worktreePlanPath)
|
||||
} finally {
|
||||
rmSync(worktreeDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("should fall back to the tracked plan when the mirrored worktree plan is missing", () => {
|
||||
// given
|
||||
const planPath = join(TEST_DIR, ".sisyphus", "plans", "fallback-plan.md")
|
||||
mkdirSync(dirname(planPath), { recursive: true })
|
||||
writeFileSync(planPath, "# Plan\n- [ ] Main repo task\n")
|
||||
|
||||
// when
|
||||
const resolvedPath = resolveBoulderPlanPath(TEST_DIR, {
|
||||
active_plan: planPath,
|
||||
worktree_path: join(tmpdir(), `missing-worktree-${Date.now()}`),
|
||||
})
|
||||
|
||||
// then
|
||||
expect(resolvedPath).toBe(planPath)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs"
|
||||
import { dirname, join, basename } from "node:path"
|
||||
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"
|
||||
import type { BoulderState, PlanProgress, TaskSessionState } from "./types"
|
||||
import { BOULDER_DIR, BOULDER_FILE, PROMETHEUS_PLANS_DIR } from "./constants"
|
||||
|
||||
@@ -15,6 +15,39 @@ export function getBoulderFilePath(directory: string): string {
|
||||
return join(directory, BOULDER_DIR, BOULDER_FILE)
|
||||
}
|
||||
|
||||
function resolveTrackedPath(baseDirectory: string, trackedPath: string): string {
|
||||
return isAbsolute(trackedPath)
|
||||
? resolve(trackedPath)
|
||||
: resolve(baseDirectory, trackedPath)
|
||||
}
|
||||
|
||||
export function resolveBoulderPlanPath(
|
||||
directory: string,
|
||||
state: Pick<BoulderState, "active_plan" | "worktree_path">,
|
||||
): string {
|
||||
const absolutePlanPath = resolveTrackedPath(directory, state.active_plan)
|
||||
const worktreePath = state.worktree_path?.trim()
|
||||
if (!worktreePath) {
|
||||
return absolutePlanPath
|
||||
}
|
||||
|
||||
const absoluteDirectory = resolve(directory)
|
||||
const relativePlanPath = relative(absoluteDirectory, absolutePlanPath)
|
||||
if (
|
||||
relativePlanPath.length === 0
|
||||
|| relativePlanPath.startsWith("..")
|
||||
|| isAbsolute(relativePlanPath)
|
||||
) {
|
||||
return absolutePlanPath
|
||||
}
|
||||
|
||||
const absoluteWorktreePath = resolveTrackedPath(directory, worktreePath)
|
||||
const worktreePlanPath = resolve(absoluteWorktreePath, relativePlanPath)
|
||||
return existsSync(worktreePlanPath)
|
||||
? worktreePlanPath
|
||||
: absolutePlanPath
|
||||
}
|
||||
|
||||
export function readBoulderState(directory: string): BoulderState | null {
|
||||
const filePath = getBoulderFilePath(directory)
|
||||
|
||||
|
||||
@@ -18,9 +18,9 @@ Analyze the user's request to determine operation mode:
|
||||
|
||||
| User Request Pattern | Mode | Jump To |
|
||||
|---------------------|------|---------|
|
||||
| "commit", "커밋", changes to commit | `COMMIT` | Phase 0-6 (existing) |
|
||||
| "rebase", "리베이스", "squash", "cleanup history" | `REBASE` | Phase R1-R4 |
|
||||
| "find when", "who changed", "언제 바뀌었", "git blame", "bisect" | `HISTORY_SEARCH` | Phase H1-H3 |
|
||||
| Commit intent in any language (e.g., "commit", "커밋", "コミット") | `COMMIT` | Phase 0-6 (existing) |
|
||||
| Rebase/squash intent in any language (e.g., "rebase", "리베이스", "リベース") | `REBASE` | Phase R1-R4 |
|
||||
| History lookup intent in any language (e.g., "find when", "언제 바뀌었", "いつ追加") | `HISTORY_SEARCH` | Phase H1-H3 |
|
||||
| "smart rebase", "rebase onto" | `REBASE` | Phase R1-R4 |
|
||||
|
||||
**CRITICAL**: Don't default to COMMIT mode. Parse the actual request.
|
||||
@@ -107,18 +107,18 @@ git log --oneline $(git merge-base HEAD main 2>/dev/null || git merge-base HEAD
|
||||
<style_detection>
|
||||
**THIS PHASE HAS MANDATORY OUTPUT** - You MUST print the analysis result before moving to Phase 2.
|
||||
|
||||
### 1.1 Language Detection
|
||||
### 1.1 Language Profile Detection
|
||||
|
||||
```
|
||||
Count from git log -30:
|
||||
- Korean characters: N commits
|
||||
- English only: M commits
|
||||
- Mixed: K commits
|
||||
- Dominant language/script patterns: N commits
|
||||
- Secondary language/script patterns: M commits
|
||||
- Mixed/ambiguous: K commits
|
||||
|
||||
DECISION:
|
||||
- If Korean >= 50% -> KOREAN
|
||||
- If English >= 50% -> ENGLISH
|
||||
- If Mixed -> Use MAJORITY language
|
||||
- Preserve the dominant repository language pattern in commit messages
|
||||
- If multiple languages are common, follow the nearest recent examples for the same module
|
||||
- Never restrict output to specific languages; support any language used by the repo (e.g., Japanese, Korean, English, etc.)
|
||||
```
|
||||
|
||||
### 1.2 Commit Style Classification
|
||||
@@ -151,9 +151,9 @@ STYLE DETECTION RESULT
|
||||
======================
|
||||
Analyzed: 30 commits from git log
|
||||
|
||||
Language: [KOREAN | ENGLISH]
|
||||
- Korean commits: N (X%)
|
||||
- English commits: M (Y%)
|
||||
Language profile: [DOMINANT_LANGUAGE_OR_SCRIPT]
|
||||
- Dominant pattern: N (X%)
|
||||
- Secondary pattern: M (Y%)
|
||||
|
||||
Style: [SEMANTIC | PLAIN | SENTENCE | SHORT]
|
||||
- Semantic (feat:, fix:, etc): N (X%)
|
||||
@@ -165,7 +165,7 @@ Reference examples from repo:
|
||||
2. "actual commit message from log"
|
||||
3. "actual commit message from log"
|
||||
|
||||
All commits will follow: [LANGUAGE] + [STYLE]
|
||||
All commits will follow: [DOMINANT_LANGUAGE_OR_SCRIPT] + [STYLE]
|
||||
```
|
||||
|
||||
**IF YOU SKIP THIS OUTPUT, YOUR COMMITS WILL BE WRONG. STOP AND REDO.**
|
||||
@@ -507,17 +507,19 @@ git log -1 --oneline
|
||||
**Based on COMMIT_CONFIG from Phase 1:**
|
||||
|
||||
```
|
||||
IF style == SEMANTIC AND language == KOREAN:
|
||||
-> "feat: 로그인 기능 추가"
|
||||
|
||||
IF style == SEMANTIC AND language == ENGLISH:
|
||||
-> "feat: add login feature"
|
||||
|
||||
IF style == PLAIN AND language == KOREAN:
|
||||
-> "로그인 기능 추가"
|
||||
|
||||
IF style == PLAIN AND language == ENGLISH:
|
||||
-> "Add login feature"
|
||||
IF style == SEMANTIC:
|
||||
-> Use a semantic prefix + repository language message
|
||||
-> Examples:
|
||||
- "feat: add login feature"
|
||||
- "feat: ログイン機能を追加"
|
||||
- "feat: 로그인 기능 추가"
|
||||
|
||||
IF style == PLAIN:
|
||||
-> Use plain repository language message without semantic prefix
|
||||
-> Examples:
|
||||
- "Add login feature"
|
||||
- "ログイン機能を追加"
|
||||
- "로그인 기능 추가"
|
||||
|
||||
IF style == SHORT:
|
||||
-> "format" / "type fix" / "lint"
|
||||
@@ -525,7 +527,7 @@ IF style == SHORT:
|
||||
|
||||
**VALIDATION before each commit:**
|
||||
1. Does message match detected style?
|
||||
2. Does language match detected language?
|
||||
2. Does message use the repository's dominant language/script profile (from Phase 1.1)?
|
||||
3. Is it similar to examples from git log?
|
||||
|
||||
If ANY check fails -> REWRITE message.
|
||||
@@ -589,7 +591,7 @@ NEXT STEPS:
|
||||
| If git log shows... | Use this style |
|
||||
|---------------------|----------------|
|
||||
| `feat: xxx`, `fix: yyy` | SEMANTIC |
|
||||
| `Add xxx`, `Fix yyy`, `xxx 추가` | PLAIN |
|
||||
| `Add xxx`, `Fix yyy`, `xxx 추가`, `xxxを追加` | PLAIN |
|
||||
| `format`, `lint`, `typo` | SHORT |
|
||||
| Full sentences | SENTENCE |
|
||||
| Mix of above | Use MAJORITY (not semantic by default) |
|
||||
@@ -691,16 +693,16 @@ USER REQUEST -> STRATEGY:
|
||||
"squash commits" / "cleanup" / "정리"
|
||||
-> INTERACTIVE_SQUASH
|
||||
|
||||
"rebase on main" / "update branch" / "메인에 리베이스"
|
||||
"rebase on main" intent in any language (e.g., "update branch", "메인에 리베이스", "mainにリベース")
|
||||
-> REBASE_ONTO_BASE
|
||||
|
||||
"autosquash" / "apply fixups"
|
||||
-> AUTOSQUASH
|
||||
|
||||
"reorder commits" / "커밋 순서"
|
||||
"reorder commits" intent in any language (e.g., "커밋 순서", "コミット順を並べ替え")
|
||||
-> INTERACTIVE_REORDER
|
||||
|
||||
"split commit" / "커밋 분리"
|
||||
"split commit" intent in any language (e.g., "커밋 분리", "コミット分割")
|
||||
-> INTERACTIVE_EDIT
|
||||
```
|
||||
</rebase_context>
|
||||
@@ -850,12 +852,12 @@ NEXT STEPS:
|
||||
|
||||
| User Request | Search Type | Tool |
|
||||
|--------------|-------------|------|
|
||||
| "when was X added" / "X가 언제 추가됐어" | PICKAXE | `git log -S` |
|
||||
| "when was X added" in any language (e.g., "X가 언제 추가됐어", "Xはいつ追加された") | PICKAXE | `git log -S` |
|
||||
| "find commits changing X pattern" | REGEX | `git log -G` |
|
||||
| "who wrote this line" / "이 줄 누가 썼어" | BLAME | `git blame` |
|
||||
| "when did bug start" / "버그 언제 생겼어" | BISECT | `git bisect` |
|
||||
| "history of file" / "파일 히스토리" | FILE_LOG | `git log -- path` |
|
||||
| "find deleted code" / "삭제된 코드 찾기" | PICKAXE_ALL | `git log -S --all` |
|
||||
| "who wrote this line" in any language (e.g., "이 줄 누가 썼어", "この行を書いたのは誰") | BLAME | `git blame` |
|
||||
| "when did bug start" in any language (e.g., "버그 언제 생겼어", "バグはいつ入った") | BISECT | `git bisect` |
|
||||
| "history of file" in any language (e.g., "파일 히스토리", "ファイル履歴") | FILE_LOG | `git log -- path` |
|
||||
| "find deleted code" in any language (e.g., "삭제된 코드 찾기", "削除されたコードを探す") | PICKAXE_ALL | `git log -S --all` |
|
||||
|
||||
### H1.2 Extract Search Parameters
|
||||
|
||||
|
||||
@@ -35,18 +35,18 @@ git log --oneline $(git merge-base HEAD main 2>/dev/null || git merge-base HEAD
|
||||
<style_detection>
|
||||
**THIS PHASE HAS MANDATORY OUTPUT** - You MUST print the analysis result before moving to Phase 2.
|
||||
|
||||
### 1.1 Language Detection
|
||||
### 1.1 Language Profile Detection
|
||||
|
||||
\`\`\`
|
||||
Count from git log -30:
|
||||
- Korean characters: N commits
|
||||
- English only: M commits
|
||||
- Mixed: K commits
|
||||
- Dominant language/script patterns: N commits
|
||||
- Secondary language/script patterns: M commits
|
||||
- Mixed/ambiguous: K commits
|
||||
|
||||
DECISION:
|
||||
- If Korean >= 50% -> KOREAN
|
||||
- If English >= 50% -> ENGLISH
|
||||
- If Mixed -> Use MAJORITY language
|
||||
- Preserve the dominant repository language pattern in commit messages
|
||||
- If multiple languages are common, follow the nearest recent examples for the same module
|
||||
- Never restrict output to specific languages; support any language used by the repo (e.g., Japanese, Korean, English, etc.)
|
||||
\`\`\`
|
||||
|
||||
### 1.2 Commit Style Classification
|
||||
@@ -79,9 +79,9 @@ STYLE DETECTION RESULT
|
||||
======================
|
||||
Analyzed: 30 commits from git log
|
||||
|
||||
Language: [KOREAN | ENGLISH]
|
||||
- Korean commits: N (X%)
|
||||
- English commits: M (Y%)
|
||||
Language profile: [DOMINANT_LANGUAGE_OR_SCRIPT]
|
||||
- Dominant pattern: N (X%)
|
||||
- Secondary pattern: M (Y%)
|
||||
|
||||
Style: [SEMANTIC | PLAIN | SENTENCE | SHORT]
|
||||
- Semantic (feat:, fix:, etc): N (X%)
|
||||
@@ -93,7 +93,7 @@ Reference examples from repo:
|
||||
2. "actual commit message from log"
|
||||
3. "actual commit message from log"
|
||||
|
||||
All commits will follow: [LANGUAGE] + [STYLE]
|
||||
All commits will follow: [DOMINANT_LANGUAGE_OR_SCRIPT] + [STYLE]
|
||||
\`\`\`
|
||||
|
||||
**IF YOU SKIP THIS OUTPUT, YOUR COMMITS WILL BE WRONG. STOP AND REDO.**
|
||||
@@ -435,17 +435,19 @@ git log -1 --oneline
|
||||
**Based on COMMIT_CONFIG from Phase 1:**
|
||||
|
||||
\`\`\`
|
||||
IF style == SEMANTIC AND language == KOREAN:
|
||||
-> "feat: 로그인 기능 추가"
|
||||
|
||||
IF style == SEMANTIC AND language == ENGLISH:
|
||||
-> "feat: add login feature"
|
||||
|
||||
IF style == PLAIN AND language == KOREAN:
|
||||
-> "로그인 기능 추가"
|
||||
|
||||
IF style == PLAIN AND language == ENGLISH:
|
||||
-> "Add login feature"
|
||||
IF style == SEMANTIC:
|
||||
-> Use a semantic prefix + repository language message
|
||||
-> Examples:
|
||||
- "feat: add login feature"
|
||||
- "feat: ログイン機能を追加"
|
||||
- "feat: 로그인 기능 추가"
|
||||
|
||||
IF style == PLAIN:
|
||||
-> Use plain repository language message without semantic prefix
|
||||
-> Examples:
|
||||
- "Add login feature"
|
||||
- "ログイン機能を追加"
|
||||
- "로그인 기능 추가"
|
||||
|
||||
IF style == SHORT:
|
||||
-> "format" / "type fix" / "lint"
|
||||
@@ -453,7 +455,7 @@ IF style == SHORT:
|
||||
|
||||
**VALIDATION before each commit:**
|
||||
1. Does message match detected style?
|
||||
2. Does language match detected language?
|
||||
2. Does message use the repository's dominant language/script profile (from Phase 1.1)?
|
||||
3. Is it similar to examples from git log?
|
||||
|
||||
If ANY check fails -> REWRITE message.
|
||||
|
||||
@@ -7,12 +7,12 @@ export const GIT_MASTER_HISTORY_SEARCH_WORKFLOW_SECTION = `## HISTORY SEARCH MOD
|
||||
|
||||
| User Request | Search Type | Tool |
|
||||
|--------------|-------------|------|
|
||||
| "when was X added" / "X가 언제 추가됐어" | PICKAXE | \`git log -S\` |
|
||||
| "when was X added" in any language (e.g., "X가 언제 추가됐어", "Xはいつ追加された") | PICKAXE | \`git log -S\` |
|
||||
| "find commits changing X pattern" | REGEX | \`git log -G\` |
|
||||
| "who wrote this line" / "이 줄 누가 썼어" | BLAME | \`git blame\` |
|
||||
| "when did bug start" / "버그 언제 생겼어" | BISECT | \`git bisect\` |
|
||||
| "history of file" / "파일 히스토리" | FILE_LOG | \`git log -- path\` |
|
||||
| "find deleted code" / "삭제된 코드 찾기" | PICKAXE_ALL | \`git log -S --all\` |
|
||||
| "who wrote this line" in any language (e.g., "이 줄 누가 썼어", "この行を書いたのは誰") | BLAME | \`git blame\` |
|
||||
| "when did bug start" in any language (e.g., "버그 언제 생겼어", "バグはいつ入った") | BISECT | \`git bisect\` |
|
||||
| "history of file" in any language (e.g., "파일 히스토리", "ファイル履歴") | FILE_LOG | \`git log -- path\` |
|
||||
| "find deleted code" in any language (e.g., "삭제된 코드 찾기", "削除されたコードを探す") | PICKAXE_ALL | \`git log -S --all\` |
|
||||
|
||||
### H1.2 Extract Search Parameters
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ Analyze the user's request to determine operation mode:
|
||||
|
||||
| User Request Pattern | Mode | Jump To |
|
||||
|---------------------|------|---------|
|
||||
| "commit", "커밋", changes to commit | \`COMMIT\` | Phase 0-6 (existing) |
|
||||
| "rebase", "리베이스", "squash", "cleanup history" | \`REBASE\` | Phase R1-R4 |
|
||||
| "find when", "who changed", "언제 바뀌었", "git blame", "bisect" | \`HISTORY_SEARCH\` | Phase H1-H3 |
|
||||
| Commit intent in any language (e.g., "commit", "커밋", "コミット") | \`COMMIT\` | Phase 0-6 (existing) |
|
||||
| Rebase/squash intent in any language (e.g., "rebase", "리베이스", "リベース") | \`REBASE\` | Phase R1-R4 |
|
||||
| History lookup intent in any language (e.g., "find when", "언제 바뀌었", "いつ追加") | \`HISTORY_SEARCH\` | Phase H1-H3 |
|
||||
| "smart rebase", "rebase onto" | \`REBASE\` | Phase R1-R4 |
|
||||
|
||||
**CRITICAL**: Don't default to COMMIT mode. Parse the actual request.
|
||||
|
||||
@@ -5,7 +5,7 @@ export const GIT_MASTER_QUICK_REFERENCE_SECTION = `## Quick Reference
|
||||
| If git log shows... | Use this style |
|
||||
|---------------------|----------------|
|
||||
| \`feat: xxx\`, \`fix: yyy\` | SEMANTIC |
|
||||
| \`Add xxx\`, \`Fix yyy\`, \`xxx 추가\` | PLAIN |
|
||||
| \`Add xxx\`, \`Fix yyy\`, \`xxx 추가\`, \`xxxを追加\` | PLAIN |
|
||||
| \`format\`, \`lint\`, \`typo\` | SHORT |
|
||||
| Full sentences | SENTENCE |
|
||||
| Mix of above | Use MAJORITY (not semantic by default) |
|
||||
|
||||
@@ -30,19 +30,19 @@ git stash list
|
||||
\`\`\`
|
||||
USER REQUEST -> STRATEGY:
|
||||
|
||||
"squash commits" / "cleanup" / "정리"
|
||||
"squash commits" intent in any language (e.g., "cleanup", "정리", "履歴整理")
|
||||
-> INTERACTIVE_SQUASH
|
||||
|
||||
"rebase on main" / "update branch" / "메인에 리베이스"
|
||||
"rebase on main" intent in any language (e.g., "update branch", "메인에 리베이스", "mainにリベース")
|
||||
-> REBASE_ONTO_BASE
|
||||
|
||||
"autosquash" / "apply fixups"
|
||||
-> AUTOSQUASH
|
||||
|
||||
"reorder commits" / "커밋 순서"
|
||||
"reorder commits" intent in any language (e.g., "커밋 순서", "コミット順を並べ替え")
|
||||
-> INTERACTIVE_REORDER
|
||||
|
||||
"split commit" / "커밋 분리"
|
||||
"split commit" intent in any language (e.g., "커밋 분리", "コミット分割")
|
||||
-> INTERACTIVE_EDIT
|
||||
\`\`\`
|
||||
</rebase_context>
|
||||
|
||||
@@ -114,7 +114,7 @@ describe("team-layout-tmux", () => {
|
||||
return null
|
||||
}
|
||||
|
||||
return { sessionId: displaySessionId }
|
||||
return { sessionId: displaySessionId, paneId: process.env.TMUX_PANE, windowTarget: "test-session:0" }
|
||||
})
|
||||
})
|
||||
|
||||
@@ -149,7 +149,7 @@ describe("team-layout-tmux", () => {
|
||||
expect(runTmuxCommandMock).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
test("creates detached focus and grid windows and sends attach via send-keys", async () => {
|
||||
test("creates teammate panes in the caller window and sends attach via send-keys", async () => {
|
||||
// given
|
||||
const { createTeamLayout } = await loadLayoutModule()
|
||||
const members = [
|
||||
@@ -162,12 +162,8 @@ describe("team-layout-tmux", () => {
|
||||
|
||||
// then
|
||||
const commands = getCommands()
|
||||
const newWindowCalls = commands.filter((args) => args[0] === "new-window")
|
||||
expect(newWindowCalls.length).toBe(2)
|
||||
expect(newWindowCalls.map((args) => args[args.indexOf("-n") + 1])).toEqual([
|
||||
"team-run-attach-focus",
|
||||
"team-run-attach-grid",
|
||||
])
|
||||
expect(commands.some((args) => args[0] === "new-window")).toBe(false)
|
||||
expect(commands.filter((args) => args[0] === "split-window")).toHaveLength(2)
|
||||
|
||||
const sendKeysCalls = commands.filter((args) => args[0] === "send-keys")
|
||||
const literals = sendKeysCalls.map((args) => args.join(" "))
|
||||
@@ -175,7 +171,7 @@ describe("team-layout-tmux", () => {
|
||||
expect(literals.some((s) => s.includes("--session 's-m2'"))).toBe(true)
|
||||
})
|
||||
|
||||
test("uses focus main-vertical and grid tiled windows", async () => {
|
||||
test("uses caller window main-vertical layout with caller pane as primary", async () => {
|
||||
// given
|
||||
const { createTeamLayout } = await loadLayoutModule()
|
||||
const members = [
|
||||
@@ -191,14 +187,14 @@ describe("team-layout-tmux", () => {
|
||||
const commands = getCommands()
|
||||
const selectLayoutArgs = commands.filter((args) => args[0] === "select-layout").map((args) => args[args.length - 1])
|
||||
expect(selectLayoutArgs).toContain("main-vertical")
|
||||
expect(selectLayoutArgs).toContain("tiled")
|
||||
expect(commands).toContainEqual(["set-window-option", "-t", "@1", "main-pane-width", "60%"])
|
||||
expect(selectLayoutArgs).not.toContain("tiled")
|
||||
expect(commands).toContainEqual(["resize-pane", "-t", process.env.TMUX_PANE ?? "", "-x", "30%"])
|
||||
expect(result).not.toBeNull()
|
||||
expect(Object.keys(result?.focusPanesByMember ?? {}).sort()).toEqual(["m1", "m2", "m3"])
|
||||
expect(Object.keys(result?.gridPanesByMember ?? {}).sort()).toEqual(["m1", "m2", "m3"])
|
||||
expect(Object.keys(result?.gridPanesByMember ?? {})).toEqual([])
|
||||
})
|
||||
|
||||
test("#given 4 or more teammates #when createTeamLayout runs #then it still keeps separate focus and grid windows", async () => {
|
||||
test("#given 4 or more teammates #when createTeamLayout runs #then it keeps every teammate in the caller window", async () => {
|
||||
// given
|
||||
const { createTeamLayout } = await loadLayoutModule()
|
||||
const members = Array.from({ length: 5 }, (_, index) => ({
|
||||
@@ -212,13 +208,11 @@ describe("team-layout-tmux", () => {
|
||||
|
||||
// then
|
||||
const commands = getCommands()
|
||||
const newWindowNames = commands
|
||||
.filter((args) => args[0] === "new-window")
|
||||
.map((args) => args[args.indexOf("-n") + 1])
|
||||
expect(newWindowNames).toEqual(["team-run-tiled-focus", "team-run-tiled-grid"])
|
||||
expect(commands.some((args) => args[0] === "new-window")).toBe(false)
|
||||
expect(commands.filter((args) => args[0] === "split-window")).toHaveLength(5)
|
||||
const selectLayoutArgs = commands.filter((args) => args[0] === "select-layout").map((args) => args[args.length - 1])
|
||||
expect(selectLayoutArgs).toContain("main-vertical")
|
||||
expect(selectLayoutArgs).toContain("tiled")
|
||||
expect(selectLayoutArgs).not.toContain("tiled")
|
||||
})
|
||||
|
||||
test("#given caller inside tmux #when createTeamLayout runs #then it never steals focus or mutates window border options", async () => {
|
||||
@@ -333,7 +327,7 @@ describe("team-layout-tmux", () => {
|
||||
})
|
||||
|
||||
describe("createTeamLayout - focus/grid window topology", () => {
|
||||
test("#given caller inside tmux #when createTeamLayout runs #then creates focus and grid windows without a new session", async () => {
|
||||
test("#given caller inside tmux #when createTeamLayout runs #then uses the caller window without a new session", async () => {
|
||||
// given
|
||||
const { createTeamLayout } = await loadLayoutModule()
|
||||
const members = [
|
||||
@@ -347,8 +341,8 @@ describe("team-layout-tmux", () => {
|
||||
// then
|
||||
const commands = getCommands()
|
||||
expect(commands.some((args) => args[0] === "new-session")).toBe(false)
|
||||
expect(commands.filter((args) => args[0] === "new-window").length).toBe(2)
|
||||
expect(commands.some((args) => args[0] === "split-window" && args.includes(process.env.TMUX_PANE ?? ""))).toBe(false)
|
||||
expect(commands.filter((args) => args[0] === "new-window").length).toBe(0)
|
||||
expect(commands.some((args) => args[0] === "split-window" && args.includes(process.env.TMUX_PANE ?? ""))).toBe(true)
|
||||
})
|
||||
|
||||
test("#given caller session resolved #when createTeamLayout runs #then ownedSession is false", async () => {
|
||||
@@ -364,7 +358,7 @@ describe("team-layout-tmux", () => {
|
||||
expect(result?.ownedSession).toBe(false)
|
||||
})
|
||||
|
||||
test("#given first teammate #when layout runs #then it creates focus and grid windows without splitting the leader pane", async () => {
|
||||
test("#given first teammate #when layout runs #then it splits the caller pane horizontally for teammate area", async () => {
|
||||
// given
|
||||
const { createTeamLayout } = await loadLayoutModule()
|
||||
const members = [{ name: "m1", sessionId: "s-m1", worktreePath: "/tmp/m1" }]
|
||||
@@ -375,8 +369,10 @@ describe("team-layout-tmux", () => {
|
||||
// then
|
||||
const commands = getCommands()
|
||||
const splitCalls = commands.filter((args) => args[0] === "split-window")
|
||||
expect(splitCalls).toEqual([])
|
||||
expect(commands.filter((args) => args[0] === "new-window").length).toBe(2)
|
||||
expect(splitCalls).toEqual([
|
||||
["split-window", "-t", process.env.TMUX_PANE ?? "", "-h", "-l", "70%", "-P", "-F", "#{pane_id}", "-c", "/tmp/m1"],
|
||||
])
|
||||
expect(commands.filter((args) => args[0] === "new-window").length).toBe(0)
|
||||
})
|
||||
|
||||
test("#given 3 members #when createTeamLayout runs #then focusPanesByMember contains 3 distinct pane ids", async () => {
|
||||
@@ -397,7 +393,7 @@ describe("team-layout-tmux", () => {
|
||||
expect(new Set(Object.values(result?.focusPanesByMember ?? {})).size).toBe(3)
|
||||
})
|
||||
|
||||
test("#given layout created #when createTeamLayout runs #then it keeps separate focus and grid pane maps", async () => {
|
||||
test("#given layout created #when createTeamLayout runs #then it records focus panes only", async () => {
|
||||
// given
|
||||
const { createTeamLayout } = await loadLayoutModule()
|
||||
const members = [
|
||||
@@ -412,9 +408,10 @@ describe("team-layout-tmux", () => {
|
||||
const commands = getCommands()
|
||||
expect(result).not.toBeNull()
|
||||
expect(Object.keys(result?.focusPanesByMember ?? {}).sort()).toEqual(["m1", "m2"])
|
||||
expect(Object.keys(result?.gridPanesByMember ?? {}).sort()).toEqual(["m1", "m2"])
|
||||
expect(result?.focusWindowId).not.toBe(result?.gridWindowId)
|
||||
expect(commands.filter((args) => args[0] === "new-window").length).toBe(2)
|
||||
expect(Object.keys(result?.gridPanesByMember ?? {})).toEqual([])
|
||||
expect(result?.focusWindowId).toBe("test-session:0")
|
||||
expect(result?.gridWindowId).toBeUndefined()
|
||||
expect(commands.filter((args) => args[0] === "new-window").length).toBe(0)
|
||||
expect(commands.some((args) => args[0] === "send-keys" && args.includes("Enter"))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ type TeamLayoutMember = { name: string; sessionId: string; worktreePath?: string
|
||||
|
||||
export type TeamLayoutResult = {
|
||||
focusWindowId: string
|
||||
gridWindowId: string
|
||||
gridWindowId?: string
|
||||
focusPanesByMember: Record<string, string>
|
||||
gridPanesByMember: Record<string, string>
|
||||
targetSessionId: string
|
||||
@@ -34,60 +34,63 @@ function buildAttachCommand(member: TeamLayoutMember, serverUrl: string): string
|
||||
return `opencode attach ${shellSingleQuote(serverUrl)} --session ${shellSingleQuote(member.sessionId)} --dir ${shellSingleQuote(getPaneWorkingDirectory(member))}`
|
||||
}
|
||||
|
||||
async function listPanesInWindow(tmuxPath: string, windowId: string): Promise<Array<string>> {
|
||||
const result = await runTmuxCommand(tmuxPath, ["list-panes", "-t", windowId, "-F", "#{pane_id}"])
|
||||
async function listPanesInWindow(tmuxPath: string, windowTarget: string): Promise<Array<string>> {
|
||||
const result = await runTmuxCommand(tmuxPath, ["list-panes", "-t", windowTarget, "-F", "#{pane_id}"])
|
||||
if (!result.success || !result.output) return []
|
||||
return result.output.trim().split("\n").filter(Boolean)
|
||||
}
|
||||
|
||||
async function createTeamWindow(
|
||||
function selectExistingTeammatePane(teammatePanes: Array<string>, callerPaneId: string): string {
|
||||
return teammatePanes[Math.floor(teammatePanes.length / 2)] ?? teammatePanes[teammatePanes.length - 1] ?? callerPaneId
|
||||
}
|
||||
|
||||
function buildSplitArgs(callerPaneId: string, teammatePanes: Array<string>, member: TeamLayoutMember): Array<string> {
|
||||
if (teammatePanes.length === 0) {
|
||||
return ["split-window", "-t", callerPaneId, "-h", "-l", "70%", "-P", "-F", "#{pane_id}", "-c", getPaneWorkingDirectory(member)]
|
||||
}
|
||||
|
||||
return [
|
||||
"split-window",
|
||||
"-t",
|
||||
selectExistingTeammatePane(teammatePanes, callerPaneId),
|
||||
teammatePanes.length % 2 === 1 ? "-v" : "-h",
|
||||
"-P",
|
||||
"-F",
|
||||
"#{pane_id}",
|
||||
"-c",
|
||||
getPaneWorkingDirectory(member),
|
||||
]
|
||||
}
|
||||
|
||||
async function createTeamLayoutInCallerWindow(
|
||||
tmuxPath: string,
|
||||
targetSessionId: string,
|
||||
windowName: string,
|
||||
layout: "main-vertical" | "tiled",
|
||||
callerPaneId: string,
|
||||
windowTarget: string,
|
||||
members: Array<TeamLayoutMember>,
|
||||
serverUrl: string,
|
||||
): Promise<{ windowId: string; panesByMember: Record<string, string> } | null> {
|
||||
const [firstMember, ...restMembers] = members
|
||||
if (!firstMember) return null
|
||||
|
||||
const created = await runTmuxCommand(tmuxPath, [
|
||||
"new-window", "-d", "-P", "-F", "#{window_id}", "-t", targetSessionId, "-n", windowName,
|
||||
"-c", getPaneWorkingDirectory(firstMember),
|
||||
])
|
||||
if (!created.success || !created.output) return null
|
||||
|
||||
const windowId = created.output.trim()
|
||||
const initialPanes = await listPanesInWindow(tmuxPath, windowId)
|
||||
const firstPaneId = initialPanes[0]
|
||||
if (!firstPaneId) return null
|
||||
|
||||
const panesByMember: Record<string, string> = { [firstMember.name]: firstPaneId }
|
||||
for (const member of restMembers) {
|
||||
const split = await runTmuxCommand(tmuxPath, [
|
||||
"split-window", "-d", "-P", "-F", "#{pane_id}", "-t", firstPaneId,
|
||||
"-c", getPaneWorkingDirectory(member),
|
||||
])
|
||||
if (!split.success || !split.output) return null
|
||||
panesByMember[member.name] = split.output.trim()
|
||||
}
|
||||
|
||||
const layoutResult = await runTmuxCommand(tmuxPath, ["select-layout", "-t", windowId, layout])
|
||||
if (!layoutResult.success) return null
|
||||
|
||||
if (layout === "main-vertical") {
|
||||
await runTmuxCommand(tmuxPath, ["set-window-option", "-t", windowId, "main-pane-width", "60%"])
|
||||
await runTmuxCommand(tmuxPath, ["select-layout", "-t", windowId, layout])
|
||||
}
|
||||
): Promise<{ focusWindowId: string; focusPanesByMember: Record<string, string> } | null> {
|
||||
const panesByMember: Record<string, string> = {}
|
||||
const existingPanes = await listPanesInWindow(tmuxPath, windowTarget)
|
||||
let teammatePanes = existingPanes.filter((paneId) => paneId !== callerPaneId)
|
||||
|
||||
for (const member of members) {
|
||||
const paneId = panesByMember[member.name]
|
||||
if (!paneId) return null
|
||||
const split = await runTmuxCommand(tmuxPath, buildSplitArgs(callerPaneId, teammatePanes, member))
|
||||
if (!split.success || !split.output) return null
|
||||
|
||||
const paneId = split.output.trim()
|
||||
teammatePanes = [...teammatePanes, paneId]
|
||||
panesByMember[member.name] = paneId
|
||||
await runTmuxCommand(tmuxPath, ["select-pane", "-t", paneId, "-T", member.name])
|
||||
await runTmuxCommand(tmuxPath, ["send-keys", "-t", paneId, buildAttachCommand(member, serverUrl), "Enter"])
|
||||
}
|
||||
|
||||
return { windowId, panesByMember }
|
||||
const layoutResult = await runTmuxCommand(tmuxPath, ["select-layout", "-t", windowTarget, "main-vertical"])
|
||||
if (!layoutResult.success) return null
|
||||
|
||||
const resizeResult = await runTmuxCommand(tmuxPath, ["resize-pane", "-t", callerPaneId, "-x", "30%"])
|
||||
if (!resizeResult.success) return null
|
||||
|
||||
return { focusWindowId: windowTarget, focusPanesByMember: panesByMember }
|
||||
}
|
||||
|
||||
export async function createTeamLayout(teamRunId: string, members: Array<TeamLayoutMember>, tmuxMgr: TmuxSessionManager): Promise<TeamLayoutResult | null> {
|
||||
@@ -111,27 +114,21 @@ export async function createTeamLayout(teamRunId: string, members: Array<TeamLay
|
||||
}
|
||||
|
||||
const callerSession = await resolveCallerTmuxSession(tmuxPath)
|
||||
const fallbackSessionName = `omo-team-${teamRunId}`
|
||||
const ownedSession = callerSession === null
|
||||
const targetSessionId = callerSession?.sessionId ?? fallbackSessionName
|
||||
|
||||
if (ownedSession) {
|
||||
log("falling back to detached team session because caller tmux session could not be resolved", { teamRunId })
|
||||
const created = await runTmuxCommand(tmuxPath, ["new-session", "-d", "-s", fallbackSessionName, "-P", "-F", "#{window_id}"])
|
||||
if (!created.success || !created.output) return null
|
||||
if (!callerSession) {
|
||||
log("tmux visualization requires a resolvable caller tmux pane, skipping", { teamRunId })
|
||||
return null
|
||||
}
|
||||
|
||||
const focus = await createTeamWindow(tmuxPath, targetSessionId, `team-${teamRunId}-focus`, "main-vertical", members, serverUrl)
|
||||
const grid = await createTeamWindow(tmuxPath, targetSessionId, `team-${teamRunId}-grid`, "tiled", members, serverUrl)
|
||||
if (!focus || !grid) return null
|
||||
const focus = await createTeamLayoutInCallerWindow(tmuxPath, callerSession.paneId, callerSession.windowTarget, members, serverUrl)
|
||||
if (!focus) return null
|
||||
|
||||
return {
|
||||
focusWindowId: focus.windowId,
|
||||
gridWindowId: grid.windowId,
|
||||
focusPanesByMember: focus.panesByMember,
|
||||
gridPanesByMember: grid.panesByMember,
|
||||
targetSessionId,
|
||||
ownedSession,
|
||||
focusWindowId: focus.focusWindowId,
|
||||
gridWindowId: undefined,
|
||||
focusPanesByMember: focus.focusPanesByMember,
|
||||
gridPanesByMember: {},
|
||||
targetSessionId: callerSession.sessionId,
|
||||
ownedSession: false,
|
||||
}
|
||||
} catch (error) {
|
||||
log("tmux visualization unavailable, skipping", { error: String(error) })
|
||||
|
||||
@@ -23,7 +23,7 @@ type TmuxManagerLike = {
|
||||
|
||||
type TeamLayoutResultLike = {
|
||||
focusWindowId: string
|
||||
gridWindowId: string
|
||||
gridWindowId?: string
|
||||
focusPanesByMember: Record<string, string>
|
||||
gridPanesByMember: Record<string, string>
|
||||
targetSessionId: string
|
||||
@@ -79,7 +79,7 @@ function isTeamLayoutResultLike(value: unknown): value is TeamLayoutResultLike {
|
||||
}
|
||||
|
||||
return typeof value.focusWindowId === "string"
|
||||
&& typeof value.gridWindowId === "string"
|
||||
&& (value.gridWindowId === undefined || typeof value.gridWindowId === "string")
|
||||
&& isRecord(value.focusPanesByMember)
|
||||
&& isRecord(value.gridPanesByMember)
|
||||
&& typeof value.targetSessionId === "string"
|
||||
@@ -210,6 +210,7 @@ async function invokeRemoveTeamLayout(
|
||||
targetSessionId,
|
||||
focusWindowId: layoutResult.focusWindowId,
|
||||
gridWindowId: layoutResult.gridWindowId,
|
||||
paneIds: Object.values(layoutResult.focusPanesByMember),
|
||||
},
|
||||
tmuxManager,
|
||||
]))
|
||||
@@ -272,13 +273,11 @@ describe("team-mode live tmux smoke", () => {
|
||||
await rm(state.tempRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test.skipIf(!LIVE)("#given a real caller tmux session and two mock members #when createTeamLayout runs #then two new windows appear in the caller session AND removeTeamLayout deletes exactly those two windows leaving the caller session intact", async () => {
|
||||
test.skipIf(!LIVE)("#given a real caller tmux session and two mock members #when createTeamLayout runs #then teammate panes appear in the caller window and cleanup leaves the session intact", async () => {
|
||||
// given
|
||||
const state = requireLiveTestState()
|
||||
const layoutModule = await loadLayoutModule()
|
||||
const teamRunId = randomUUID()
|
||||
const shortTeamRunId = teamRunId.slice(0, 8)
|
||||
const expectedWindowNames = [`focus-${shortTeamRunId}`, `grid-${shortTeamRunId}`]
|
||||
const initialWindows = await listWindows(state.callerSessionId)
|
||||
const members: TeamLayoutMemberLike[] = [
|
||||
{
|
||||
@@ -295,25 +294,33 @@ describe("team-mode live tmux smoke", () => {
|
||||
|
||||
// when
|
||||
const layoutResult = await invokeCreateTeamLayout(layoutModule, teamRunId, members, state.tmuxManager)
|
||||
const windowsAppeared = await waitForCondition(async () => {
|
||||
const panesAppeared = await waitForCondition(async () => {
|
||||
const panes = await runTmuxCommand(["list-panes", "-t", state.callerSessionId, "-F", "#{pane_id}"])
|
||||
return panes.success && Object.values(layoutResult.focusPanesByMember).every((paneId) => panes.stdout.split("\n").includes(paneId))
|
||||
})
|
||||
const windowsUnchangedBeforeCleanup = await waitForCondition(async () => {
|
||||
const windows = await listWindows(state.callerSessionId)
|
||||
return expectedWindowNames.every((windowName) => windows.some((window) => window.name === windowName))
|
||||
return windows.map((window) => window.id).join(",") === initialWindows.map((window) => window.id).join(",")
|
||||
})
|
||||
|
||||
await invokeRemoveTeamLayout(layoutModule, teamRunId, state.tmuxManager, layoutResult, state.callerSessionId)
|
||||
const windowsRemoved = await waitForCondition(async () => {
|
||||
const panesRemoved = await waitForCondition(async () => {
|
||||
const panes = await runTmuxCommand(["list-panes", "-t", state.callerSessionId, "-F", "#{pane_id}"])
|
||||
return panes.success && Object.values(layoutResult.focusPanesByMember).every((paneId) => !panes.stdout.split("\n").includes(paneId))
|
||||
})
|
||||
const windowsUnchangedAfterCleanup = await waitForCondition(async () => {
|
||||
const windows = await listWindows(state.callerSessionId)
|
||||
const noExpectedWindowsRemain = expectedWindowNames.every((windowName) => windows.every((window) => window.name !== windowName))
|
||||
const sameWindowIds = windows.map((window) => window.id).join(",") === initialWindows.map((window) => window.id).join(",")
|
||||
return noExpectedWindowsRemain && sameWindowIds
|
||||
return windows.map((window) => window.id).join(",") === initialWindows.map((window) => window.id).join(",")
|
||||
})
|
||||
const callerSessionStillAlive = await runTmuxCommand(["has-session", "-t", state.callerSessionId])
|
||||
|
||||
// then
|
||||
expect(layoutResult.focusWindowId.length).toBeGreaterThan(0)
|
||||
expect(layoutResult.gridWindowId.length).toBeGreaterThan(0)
|
||||
expect(windowsAppeared).toBe(true)
|
||||
expect(windowsRemoved).toBe(true)
|
||||
expect(layoutResult.gridWindowId).toBeUndefined()
|
||||
expect(panesAppeared).toBe(true)
|
||||
expect(windowsUnchangedBeforeCleanup).toBe(true)
|
||||
expect(panesRemoved).toBe(true)
|
||||
expect(windowsUnchangedAfterCleanup).toBe(true)
|
||||
expect(callerSessionStillAlive.success).toBe(true)
|
||||
expect(process.env.TMUX_PANE).toBe(state.callerPaneId)
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@ function shellSingleQuote(value: string): string {
|
||||
return `'${value.split("'").join(`'"'"'`)}'`
|
||||
}
|
||||
|
||||
async function createTmuxStub(options: { stdout: string; exitCode: number }): Promise<TmuxStub> {
|
||||
async function createTmuxStub(options: { stdout: string; windowStdout?: string; exitCode: number }): Promise<TmuxStub> {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "resolve-caller-tmux-session-"))
|
||||
temporaryDirectories.push(directory)
|
||||
|
||||
@@ -27,7 +27,7 @@ async function createTmuxStub(options: { stdout: string; exitCode: number }): Pr
|
||||
const script = [
|
||||
"#!/bin/sh",
|
||||
`printf '%s\\n' \"$@\" >> ${shellSingleQuote(logPath)}`,
|
||||
`printf '%s' ${shellSingleQuote(options.stdout)}`,
|
||||
`case "$*" in *'#{session_name}:#{window_index}'*) printf '%s' ${shellSingleQuote(options.windowStdout ?? options.stdout)} ;; *) printf '%s' ${shellSingleQuote(options.stdout)} ;; esac`,
|
||||
`exit ${options.exitCode}`,
|
||||
].join("\n")
|
||||
|
||||
@@ -67,17 +67,20 @@ describe("resolveCallerTmuxSession", () => {
|
||||
expect(await readLogLines(stub.logPath)).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("#given TMUX_PANE=%42 and display returns '$7' #when resolve runs #then returns { sessionId: '$7' }", async () => {
|
||||
test("#given TMUX_PANE=%42 and display returns session and window #when resolve runs #then returns caller tmux target", async () => {
|
||||
// given
|
||||
process.env.TMUX_PANE = "%42"
|
||||
const stub = await createTmuxStub({ stdout: "$7", exitCode: 0 })
|
||||
const stub = await createTmuxStub({ stdout: "$7", windowStdout: "test-session:0", exitCode: 0 })
|
||||
|
||||
// when
|
||||
const result = await resolveCallerTmuxSession(stub.tmuxPath)
|
||||
|
||||
// then
|
||||
expect(result).toEqual({ sessionId: "$7" })
|
||||
expect(await readLogLines(stub.logPath)).toEqual(["display", "-p", "-F", "#{session_id}", "-t", "%42"])
|
||||
expect(result).toEqual({ sessionId: "$7", paneId: "%42", windowTarget: "test-session:0" })
|
||||
expect(await readLogLines(stub.logPath)).toEqual([
|
||||
"display", "-p", "-F", "#{session_id}", "-t", "%42",
|
||||
"display", "-p", "-F", "#{session_name}:#{window_index}", "-t", "%42",
|
||||
])
|
||||
})
|
||||
|
||||
test("#given TMUX_PANE=%42 and display returns 'garbage' #when resolve runs #then returns null", async () => {
|
||||
|
||||
@@ -2,9 +2,12 @@ import { runTmuxCommand } from "../../../shared/tmux"
|
||||
|
||||
type ResolvedCallerTmuxSession = {
|
||||
sessionId: string
|
||||
paneId: string
|
||||
windowTarget: string
|
||||
}
|
||||
|
||||
const TMUX_SESSION_ID_PATTERN = /^\$[0-9]+$/
|
||||
const TMUX_WINDOW_TARGET_PATTERN = /^[^:]+:[0-9]+$/
|
||||
|
||||
export async function resolveCallerTmuxSession(tmuxPath: string): Promise<ResolvedCallerTmuxSession | null> {
|
||||
const callerPaneId = process.env.TMUX_PANE
|
||||
@@ -12,15 +15,25 @@ export async function resolveCallerTmuxSession(tmuxPath: string): Promise<Resolv
|
||||
return null
|
||||
}
|
||||
|
||||
const result = await runTmuxCommand(tmuxPath, ["display", "-p", "-F", "#{session_id}", "-t", callerPaneId])
|
||||
if (!result.success) {
|
||||
const sessionResult = await runTmuxCommand(tmuxPath, ["display", "-p", "-F", "#{session_id}", "-t", callerPaneId])
|
||||
if (!sessionResult.success) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sessionId = result.output.trim()
|
||||
const sessionId = sessionResult.output.trim()
|
||||
if (!TMUX_SESSION_ID_PATTERN.test(sessionId)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { sessionId }
|
||||
const windowResult = await runTmuxCommand(tmuxPath, ["display", "-p", "-F", "#{session_name}:#{window_index}", "-t", callerPaneId])
|
||||
if (!windowResult.success) {
|
||||
return null
|
||||
}
|
||||
|
||||
const windowTarget = windowResult.output.trim()
|
||||
if (!TMUX_WINDOW_TARGET_PATTERN.test(windowTarget)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { sessionId, paneId: callerPaneId, windowTarget }
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ export async function deleteTeam(
|
||||
}
|
||||
}
|
||||
|
||||
const removedLayout = tmuxMgr !== undefined && canVisualize()
|
||||
const removedLayout = config.tmux_visualization && tmuxMgr !== undefined && canVisualize()
|
||||
if (removedLayout) {
|
||||
const memberPaneIds = runtimeState.members
|
||||
.filter((member) => member.agentType !== "leader" && member.tmuxPaneId)
|
||||
|
||||
@@ -305,7 +305,7 @@ describe("team-runtime shutdown", () => {
|
||||
// when
|
||||
const result = await deleteTeam(
|
||||
fixture.teamRunId,
|
||||
fixture.config,
|
||||
{ ...fixture.config, tmux_visualization: true },
|
||||
{ getServerUrl: () => "http://localhost" } as never,
|
||||
undefined,
|
||||
{ force: true },
|
||||
@@ -325,6 +325,29 @@ describe("team-runtime shutdown", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("#given tmux manager but visualization disabled #when deleteTeam runs #then layout cleanup is skipped", async () => {
|
||||
// given
|
||||
const fixture = await createFixture()
|
||||
temporaryDirectories.push(fixture.baseDir)
|
||||
spyOn(layoutModule, "canVisualize").mockReturnValue(true)
|
||||
const removeLayoutSpy = spyOn(layoutModule, "removeTeamLayout").mockResolvedValue(undefined)
|
||||
await updateMemberStatuses(fixture.teamRunId, fixture.config, {
|
||||
"member-a": "shutdown_approved",
|
||||
"member-b": "completed",
|
||||
})
|
||||
|
||||
// when
|
||||
const result = await deleteTeam(
|
||||
fixture.teamRunId,
|
||||
{ ...fixture.config, tmux_visualization: false },
|
||||
{ getServerUrl: () => "http://localhost" } as never,
|
||||
)
|
||||
|
||||
// then
|
||||
expect(result.removedLayout).toBe(false)
|
||||
expect(removeLayoutSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("cancels team background tasks before deleting when force=true", async () => {
|
||||
// given
|
||||
const fixture = await createFixture()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { appendSessionId, type BoulderState, upsertTaskSessionState } from "../../features/boulder-state"
|
||||
import { appendSessionId, type BoulderState, resolveBoulderPlanPath, upsertTaskSessionState } from "../../features/boulder-state"
|
||||
import { log } from "../../shared/logger"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
import { extractSessionIdFromOutput, validateSubagentSessionId } from "./subagent-session-id"
|
||||
@@ -40,7 +40,7 @@ export async function syncBackgroundLaunchSessionTracking(input: {
|
||||
|
||||
const { currentTask, shouldSkipTaskSessionUpdate } = resolveTaskContext(
|
||||
pendingTaskRef,
|
||||
boulderState.active_plan,
|
||||
resolveBoulderPlanPath(ctx.directory, boulderState),
|
||||
)
|
||||
|
||||
if (currentTask && !shouldSkipTaskSessionUpdate) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
getTaskSessionState,
|
||||
readBoulderState,
|
||||
readCurrentTopLevelTask,
|
||||
resolveBoulderPlanPath,
|
||||
} from "../../features/boulder-state"
|
||||
import { getSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { getLastAgentFromSession } from "./session-last-agent"
|
||||
@@ -52,8 +53,12 @@ async function injectContinuation(input: {
|
||||
|
||||
try {
|
||||
const currentBoulder = readBoulderState(input.ctx.directory)
|
||||
const currentPlanPath = currentBoulder
|
||||
? resolveBoulderPlanPath(input.ctx.directory, currentBoulder)
|
||||
: null
|
||||
const currentTask = currentBoulder
|
||||
? readCurrentTopLevelTask(currentBoulder.active_plan)
|
||||
&& currentPlanPath
|
||||
? readCurrentTopLevelTask(currentPlanPath)
|
||||
: null
|
||||
const preferredTaskSession = currentTask
|
||||
? getTaskSessionState(input.ctx.directory, currentTask.key)
|
||||
@@ -163,7 +168,7 @@ function scheduleRetry(input: {
|
||||
if (!currentBoulder) return
|
||||
if (!currentBoulder.session_ids?.includes(sessionID)) return
|
||||
|
||||
const currentProgress = getPlanProgress(currentBoulder.active_plan)
|
||||
const currentProgress = getPlanProgress(resolveBoulderPlanPath(ctx.directory, currentBoulder))
|
||||
if (currentProgress.isComplete) return
|
||||
if (options?.isContinuationStopped?.(sessionID)) return
|
||||
const canContinueSession = await canContinueTrackedBoulderSession({
|
||||
|
||||
@@ -1494,6 +1494,43 @@ session_id: ses_untrusted_999
|
||||
expect(mockInput._promptMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("should not inject when the mirrored worktree plan is complete even if the main repo plan is stale", async () => {
|
||||
// given
|
||||
const mainPlanPath = join(TEST_DIR, ".sisyphus", "plans", "worktree-complete-plan.md")
|
||||
const worktreeDir = join(tmpdir(), `atlas-worktree-${randomUUID()}`)
|
||||
const worktreePlanPath = join(worktreeDir, ".sisyphus", "plans", "worktree-complete-plan.md")
|
||||
mkdirSync(join(TEST_DIR, ".sisyphus", "plans"), { recursive: true })
|
||||
mkdirSync(join(worktreeDir, ".sisyphus", "plans"), { recursive: true })
|
||||
writeFileSync(mainPlanPath, "# Plan\n- [ ] Main repo task\n")
|
||||
writeFileSync(worktreePlanPath, "# Plan\n- [x] Worktree task\n")
|
||||
|
||||
writeBoulderState(TEST_DIR, {
|
||||
active_plan: mainPlanPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [MAIN_SESSION_ID],
|
||||
plan_name: "worktree-complete-plan",
|
||||
worktree_path: worktreeDir,
|
||||
})
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput)
|
||||
|
||||
try {
|
||||
// when
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: MAIN_SESSION_ID },
|
||||
},
|
||||
})
|
||||
|
||||
// then
|
||||
expect(mockInput._promptMock).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
rmSync(worktreeDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("should skip when abort error occurred before idle", async () => {
|
||||
// given - boulder state with incomplete plan
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { dirname, join } from "node:path"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { clearBoulderState, writeBoulderState } from "../../features/boulder-state"
|
||||
import { resolveActiveBoulderSession } from "./resolve-active-boulder-session"
|
||||
@@ -96,4 +96,39 @@ describe("resolveActiveBoulderSession", () => {
|
||||
expect(result?.progress.isComplete).toBe(false)
|
||||
expect(result?.boulderState.session_ids).toContain("ses_appended")
|
||||
})
|
||||
|
||||
test("returns complete progress when a mirrored worktree plan is complete", async () => {
|
||||
// given
|
||||
const mainPlanPath = join(testDirectory, ".sisyphus", "plans", "worktree-plan.md")
|
||||
const worktreeDirectory = join(tmpdir(), `resolve-active-boulder-worktree-${randomUUID()}`)
|
||||
const worktreePlanPath = join(worktreeDirectory, ".sisyphus", "plans", "worktree-plan.md")
|
||||
mkdirSync(dirname(mainPlanPath), { recursive: true })
|
||||
mkdirSync(dirname(worktreePlanPath), { recursive: true })
|
||||
writeFileSync(mainPlanPath, "# Plan\n- [ ] Main repo task\n", "utf-8")
|
||||
writeFileSync(worktreePlanPath, "# Plan\n- [x] Worktree task\n", "utf-8")
|
||||
writeBoulderState(testDirectory, {
|
||||
active_plan: mainPlanPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: ["ses_tracked"],
|
||||
session_origins: { ses_tracked: "direct" },
|
||||
plan_name: "worktree-plan",
|
||||
worktree_path: worktreeDirectory,
|
||||
})
|
||||
|
||||
try {
|
||||
// when
|
||||
const result = await resolveActiveBoulderSession({
|
||||
client: { session: { get: async () => ({ data: {} }) } } as never,
|
||||
directory: testDirectory,
|
||||
sessionID: "ses_tracked",
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.progress.isComplete).toBe(true)
|
||||
expect(result?.progress.completed).toBe(1)
|
||||
} finally {
|
||||
rmSync(worktreeDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { getPlanProgress, readBoulderState } from "../../features/boulder-state"
|
||||
import { getPlanProgress, readBoulderState, resolveBoulderPlanPath } from "../../features/boulder-state"
|
||||
import type { BoulderState, PlanProgress } from "../../features/boulder-state"
|
||||
|
||||
export async function resolveActiveBoulderSession(input: {
|
||||
@@ -20,7 +20,7 @@ export async function resolveActiveBoulderSession(input: {
|
||||
return null
|
||||
}
|
||||
|
||||
const progress = getPlanProgress(boulderState.active_plan)
|
||||
const progress = getPlanProgress(resolveBoulderPlanPath(input.directory, boulderState))
|
||||
if (progress.isComplete) {
|
||||
return { boulderState, progress, appendedSession: false }
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
getPlanProgress,
|
||||
getTaskSessionState,
|
||||
readBoulderState,
|
||||
resolveBoulderPlanPath,
|
||||
upsertTaskSessionState,
|
||||
} from "../../features/boulder-state"
|
||||
import { log } from "../../shared/logger"
|
||||
@@ -98,12 +99,13 @@ export function createToolExecuteAfterHandler(input: {
|
||||
const extractedSessionId = metadataSessionId ?? extractSessionIdFromOutput(toolOutput.output)
|
||||
|
||||
if (boulderState) {
|
||||
const progress = getPlanProgress(boulderState.active_plan)
|
||||
const planPath = resolveBoulderPlanPath(ctx.directory, boulderState)
|
||||
const progress = getPlanProgress(planPath)
|
||||
const {
|
||||
currentTask,
|
||||
shouldSkipTaskSessionUpdate,
|
||||
shouldIgnoreCurrentSessionId,
|
||||
} = resolveTaskContext(pendingTaskRef, boulderState.active_plan)
|
||||
} = resolveTaskContext(pendingTaskRef, planPath)
|
||||
const trackedTaskSession = currentTask
|
||||
? getTaskSessionState(ctx.directory, currentTask.key)
|
||||
: null
|
||||
@@ -136,7 +138,7 @@ export function createToolExecuteAfterHandler(input: {
|
||||
const originalResponse = toolOutput.output
|
||||
const shouldPauseForApproval = sessionState
|
||||
? shouldPauseForFinalWaveApproval({
|
||||
planPath: boulderState.active_plan,
|
||||
planPath,
|
||||
taskOutput: originalResponse,
|
||||
sessionState,
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { log } from "../../shared/logger"
|
||||
import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive"
|
||||
import { isCallerOrchestrator } from "../../shared/session-utils"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { readBoulderState, readCurrentTopLevelTask } from "../../features/boulder-state"
|
||||
import { readBoulderState, readCurrentTopLevelTask, resolveBoulderPlanPath } from "../../features/boulder-state"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
import { ORCHESTRATOR_DELEGATION_REQUIRED, SINGLE_TASK_DIRECTIVE } from "./system-reminder-templates"
|
||||
import { isSisyphusPath } from "./sisyphus-path"
|
||||
@@ -60,7 +60,7 @@ export function createToolExecuteBeforeHandler(input: {
|
||||
} else {
|
||||
const boulderState = readBoulderState(ctx.directory)
|
||||
const currentTask = boulderState
|
||||
? readCurrentTopLevelTask(boulderState.active_plan)
|
||||
? readCurrentTopLevelTask(resolveBoulderPlanPath(ctx.directory, boulderState))
|
||||
: null
|
||||
if (currentTask) {
|
||||
const task = {
|
||||
|
||||
@@ -26,6 +26,10 @@ export async function processFilePathForAgentsInjection(input: {
|
||||
sessionID: string;
|
||||
output: { title: string; output: string; metadata: unknown };
|
||||
}): Promise<void> {
|
||||
// Guard: output.output may be non-string at runtime (e.g. MCP bridge format changes).
|
||||
// Consistent with the pattern used in tool-output-truncator and other hooks.
|
||||
if (typeof input.output.output !== "string") return;
|
||||
|
||||
const resolved = resolveFilePath(input.ctx.directory, input.filePath);
|
||||
if (!resolved) return;
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ describe("model fallback hook", () => {
|
||||
|
||||
expect(secondOutput.message["model"]).toEqual({
|
||||
providerID: "opencode-go",
|
||||
modelID: "kimi-k2.5",
|
||||
modelID: "kimi-k2.6",
|
||||
})
|
||||
expect(secondOutput.message["variant"]).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -88,7 +88,6 @@ describe("ralph-loop non-abort error continuation", () => {
|
||||
expect(messagesCalls.length).toBeGreaterThan(0)
|
||||
expect(hook.getState()?.iteration).toBe(2)
|
||||
})
|
||||
|
||||
test("continues ultrawork loop immediately after non-abort session error", async () => {
|
||||
// given - an active ULW Loop receives a recoverable runtime error
|
||||
const hook = createRalphLoopHook({
|
||||
|
||||
@@ -132,7 +132,8 @@ export function classifyErrorType(error: unknown): string | undefined {
|
||||
/exhausted\s+your\s+capacity/i.test(message) ||
|
||||
/out\s+of\s+credits?/i.test(message) ||
|
||||
/payment.?required/i.test(message) ||
|
||||
/usage\s+limit/i.test(message)
|
||||
/usage\s+limit/i.test(message) ||
|
||||
/credit\s+balance.*too\s+low/i.test(message)
|
||||
) {
|
||||
return "quota_exceeded"
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getPlanName,
|
||||
getPlanProgress,
|
||||
readBoulderState,
|
||||
resolveBoulderPlanPath,
|
||||
writeBoulderState,
|
||||
} from "../../features/boulder-state"
|
||||
import { log } from "../../shared/logger"
|
||||
@@ -150,7 +151,8 @@ function buildExistingSessionContext(params: {
|
||||
directory: string
|
||||
}): string {
|
||||
const { existingState, sessionId, activeAgent, worktreePath, worktreeBlock, directory } = params
|
||||
const progress = getPlanProgress(existingState.active_plan)
|
||||
const planPath = resolveBoulderPlanPath(directory, existingState)
|
||||
const progress = getPlanProgress(planPath)
|
||||
if (progress.isComplete) {
|
||||
return `
|
||||
## Previous Work Complete
|
||||
@@ -186,7 +188,7 @@ Looking for new plans...`
|
||||
|
||||
**Status**: RESUMING existing work
|
||||
**Plan**: ${existingState.plan_name}
|
||||
**Path**: ${existingState.active_plan}
|
||||
**Path**: ${planPath}
|
||||
**Progress**: ${progress.completed}/${progress.total} tasks completed
|
||||
**Sessions**: ${existingState.session_ids.length + 1} (current session appended)
|
||||
**Started**: ${existingState.started_at}
|
||||
@@ -197,11 +199,16 @@ Read the plan file and continue from the first unchecked task.`
|
||||
}
|
||||
|
||||
function shouldDiscoverPlans(
|
||||
directory: string,
|
||||
existingState: ReturnType<typeof readBoulderState>,
|
||||
explicitPlanName: string | null,
|
||||
): boolean {
|
||||
return (!existingState && !explicitPlanName)
|
||||
|| (existingState !== null && !explicitPlanName && getPlanProgress(existingState.active_plan).isComplete)
|
||||
|| (
|
||||
existingState !== null
|
||||
&& !explicitPlanName
|
||||
&& getPlanProgress(resolveBoulderPlanPath(directory, existingState)).isComplete
|
||||
)
|
||||
}
|
||||
|
||||
function buildPlanDiscoveryContext(params: {
|
||||
@@ -303,7 +310,7 @@ export function buildStartWorkContextInfo(params: {
|
||||
})
|
||||
}
|
||||
|
||||
if (shouldDiscoverPlans(existingState, explicitPlanName)) {
|
||||
if (shouldDiscoverPlans(ctx.directory, existingState, explicitPlanName)) {
|
||||
return buildPlanDiscoveryContext({
|
||||
contextInfo,
|
||||
sessionId,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { dirname, join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { createStartWorkHook } from "./index"
|
||||
@@ -1013,5 +1013,39 @@ You are starting a Sisyphus work session.
|
||||
expect(output.parts[0].text).toContain("subagent")
|
||||
expect(output.parts[0].text).not.toContain("Worktree Setup Required")
|
||||
})
|
||||
|
||||
test("should show worktree plan progress and path when the mirrored plan exists", async () => {
|
||||
// given
|
||||
const mainPlanPath = join(testDir, ".sisyphus", "plans", "resume-worktree-plan.md")
|
||||
const worktreeDir = join(testDir, "..", `resume-worktree-${randomUUID()}`)
|
||||
const worktreePlanPath = join(worktreeDir, ".sisyphus", "plans", "resume-worktree-plan.md")
|
||||
mkdirSync(dirname(mainPlanPath), { recursive: true })
|
||||
mkdirSync(dirname(worktreePlanPath), { recursive: true })
|
||||
writeFileSync(mainPlanPath, "# Plan\n- [ ] Main repo task\n")
|
||||
writeFileSync(worktreePlanPath, "# Plan\n- [x] Worktree task 1\n- [ ] Worktree task 2\n")
|
||||
writeBoulderState(testDir, {
|
||||
active_plan: mainPlanPath,
|
||||
started_at: "2026-01-01T00:00:00Z",
|
||||
session_ids: ["old-session"],
|
||||
plan_name: "resume-worktree-plan",
|
||||
worktree_path: worktreeDir,
|
||||
})
|
||||
|
||||
const hook = createStartWorkHook(createMockPluginInput())
|
||||
const output = {
|
||||
parts: [{ type: "text", text: createStartWorkPrompt() }],
|
||||
}
|
||||
|
||||
try {
|
||||
// when
|
||||
await hook["chat.message"]({ sessionID: "session-worktree-progress" }, output)
|
||||
|
||||
// then
|
||||
expect(output.parts[0].text).toContain(worktreePlanPath)
|
||||
expect(output.parts[0].text).toContain("1/2 tasks completed")
|
||||
} finally {
|
||||
rmSync(worktreeDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,45 @@ import { handleSessionIdle } from "./idle-event"
|
||||
import { handleNonIdleEvent } from "./non-idle-events"
|
||||
import { isTokenLimitError } from "./token-limit-detection"
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return typeof value === "object" && value !== null ? value as Record<string, unknown> : undefined
|
||||
}
|
||||
|
||||
function getStringField(record: Record<string, unknown> | undefined, key: string): string | undefined {
|
||||
const value = record?.[key]
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function extractSessionErrorInfo(error: unknown): { name?: string; message?: string } | undefined {
|
||||
if (!error) return undefined
|
||||
if (typeof error === "string") return { message: error }
|
||||
if (error instanceof Error) return { name: error.name, message: error.message }
|
||||
|
||||
const root = asRecord(error)
|
||||
if (!root) return { message: String(error) }
|
||||
|
||||
const data = asRecord(root.data)
|
||||
const nestedError = asRecord(root.error)
|
||||
const dataError = asRecord(data?.error)
|
||||
|
||||
const name = getStringField(root, "name")
|
||||
?? getStringField(data, "name")
|
||||
?? getStringField(nestedError, "name")
|
||||
?? getStringField(dataError, "name")
|
||||
|
||||
const messageParts = [
|
||||
getStringField(root, "message"),
|
||||
getStringField(data, "message"),
|
||||
getStringField(nestedError, "message"),
|
||||
getStringField(dataError, "message"),
|
||||
getStringField(root, "code"),
|
||||
getStringField(nestedError, "code"),
|
||||
getStringField(dataError, "code"),
|
||||
].filter((message): message is string => typeof message === "string")
|
||||
|
||||
return { name, message: messageParts.join(" ") || undefined }
|
||||
}
|
||||
|
||||
export function createTodoContinuationHandler(args: {
|
||||
ctx: PluginInput
|
||||
sessionStateStore: SessionStateStore
|
||||
@@ -35,7 +74,8 @@ export function createTodoContinuationHandler(args: {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
const error = props?.error as { name?: string; message?: string } | undefined
|
||||
const error = extractSessionErrorInfo(props?.error)
|
||||
let shouldCancelCountdown = false
|
||||
if (error?.name === "MessageAbortedError" || error?.name === "AbortError") {
|
||||
const state = sessionStateStore.getState(sessionID)
|
||||
state.wasCancelled = true
|
||||
@@ -45,14 +85,18 @@ export function createTodoContinuationHandler(args: {
|
||||
state.awaitingPostInjectionProgressCheck = false
|
||||
state.stagnationCount = 0
|
||||
state.consecutiveFailures = 0
|
||||
shouldCancelCountdown = true
|
||||
log(`[${HOOK_NAME}] Abort detected via session.error`, { sessionID, errorName: error.name })
|
||||
} else if (isTokenLimitError(error)) {
|
||||
const state = sessionStateStore.getState(sessionID)
|
||||
state.tokenLimitDetected = true
|
||||
shouldCancelCountdown = true
|
||||
log(`[${HOOK_NAME}] Token limit error detected via session.error`, { sessionID, errorName: error?.name, errorMessage: error?.message })
|
||||
}
|
||||
|
||||
sessionStateStore.cancelCountdown(sessionID)
|
||||
if (shouldCancelCountdown) {
|
||||
sessionStateStore.cancelCountdown(sessionID)
|
||||
}
|
||||
log(`[${HOOK_NAME}] session.error`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { _resetForTesting, setMainSession } from "../../features/claude-code-session-state"
|
||||
import { createTodoContinuationEnforcer } from "."
|
||||
|
||||
type PromptCall = {
|
||||
sessionID: string
|
||||
text: string
|
||||
}
|
||||
|
||||
type PromptInput = {
|
||||
path: { id: string }
|
||||
body: { parts: Array<{ text: string }> }
|
||||
}
|
||||
|
||||
function wait(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function createPluginInput(promptCalls: PromptCall[]): Parameters<typeof createTodoContinuationEnforcer>[0] {
|
||||
return {
|
||||
directory: "/tmp/opencode-overload-continuation-test",
|
||||
client: {
|
||||
session: {
|
||||
todo: async () => ({
|
||||
data: [
|
||||
{ id: "1", content: "Keep working", status: "pending", priority: "high" },
|
||||
],
|
||||
}),
|
||||
messages: async () => ({ data: [] }),
|
||||
promptAsync: async (input: PromptInput) => {
|
||||
promptCalls.push({
|
||||
sessionID: input.path.id,
|
||||
text: input.body.parts[0]?.text ?? "",
|
||||
})
|
||||
return {}
|
||||
},
|
||||
},
|
||||
tui: {
|
||||
showToast: async () => ({}),
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof createTodoContinuationEnforcer>[0]
|
||||
}
|
||||
|
||||
describe("todo-continuation-enforcer OpenCode overload errors", () => {
|
||||
test(
|
||||
"#given countdown is armed #when OpenCode reports server_is_overloaded #then continuation still injects",
|
||||
async () => {
|
||||
// given
|
||||
const sessionID = "main-opencode-overload"
|
||||
const promptCalls: PromptCall[] = []
|
||||
_resetForTesting()
|
||||
setMainSession(sessionID)
|
||||
const hook = createTodoContinuationEnforcer(createPluginInput(promptCalls))
|
||||
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
// when
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
error: {
|
||||
type: "error",
|
||||
sequence_number: 2,
|
||||
error: {
|
||||
type: "service_unavailable_error",
|
||||
code: "server_is_overloaded",
|
||||
message: "Our servers are currently overloaded. Please try again later.",
|
||||
param: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await wait(2500)
|
||||
|
||||
// then
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
expect(promptCalls[0]?.sessionID).toBe(sessionID)
|
||||
expect(promptCalls[0]?.text).toContain("TODO CONTINUATION")
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
})
|
||||
+330
-3
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, mock } from "bun:test";
|
||||
import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { afterEach, describe, expect, it, mock, spyOn } from "bun:test";
|
||||
import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { mergeConfigs, parseConfigPartially } from "./plugin-config";
|
||||
@@ -560,7 +560,6 @@ describe("loadPluginConfig", () => {
|
||||
git_env_prefix: "GIT_MASTER=1",
|
||||
})
|
||||
})
|
||||
|
||||
describe("team_mode.tmux_visualization", () => {
|
||||
it("#given canonical user config enables team_mode and legacy config also exists #when loadPluginConfig runs #then tmux_visualization remains false", async () => {
|
||||
// given
|
||||
@@ -639,4 +638,332 @@ describe("loadPluginConfig", () => {
|
||||
expect(config.team_mode).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
it("should merge configs from ancestor directories with closer winning", async () => {
|
||||
// given
|
||||
const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-walk-"))
|
||||
const userConfigDir = join(rootDir, "user-config")
|
||||
const homeDir = join(rootDir, "home")
|
||||
const workDir = join(homeDir, "work")
|
||||
const projectDir = join(workDir, "project")
|
||||
|
||||
tempDirs.push(rootDir)
|
||||
mkdirSync(userConfigDir, { recursive: true })
|
||||
mkdirSync(join(homeDir, ".opencode"), { recursive: true })
|
||||
mkdirSync(join(workDir, ".opencode"), { recursive: true })
|
||||
mkdirSync(join(projectDir, ".opencode"), { recursive: true })
|
||||
|
||||
writeFileSync(
|
||||
join(userConfigDir, "oh-my-openagent.jsonc"),
|
||||
JSON.stringify({ agents: { oracle: { model: "user/model" } } })
|
||||
)
|
||||
writeFileSync(
|
||||
join(homeDir, ".opencode", "oh-my-openagent.jsonc"),
|
||||
JSON.stringify({ agents: { oracle: { model: "home/model" } } })
|
||||
)
|
||||
writeFileSync(
|
||||
join(workDir, ".opencode", "oh-my-openagent.jsonc"),
|
||||
JSON.stringify({ agents: { oracle: { model: "work/model" } } })
|
||||
)
|
||||
writeFileSync(
|
||||
join(projectDir, ".opencode", "oh-my-openagent.jsonc"),
|
||||
JSON.stringify({ agents: { oracle: { model: "project/model" } } })
|
||||
)
|
||||
|
||||
process.env.OPENCODE_CONFIG_DIR = userConfigDir
|
||||
process.env.HOME = homeDir
|
||||
|
||||
// when
|
||||
const { loadPluginConfig } = await importFreshPluginConfigModule()
|
||||
const config = loadPluginConfig(projectDir, {})
|
||||
|
||||
// then
|
||||
expect(config.agents?.oracle?.model).toBe("project/model")
|
||||
})
|
||||
|
||||
it("should layer ancestor configs so each contributes fields not overridden by closer ones", async () => {
|
||||
// given
|
||||
const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-walk-layer-"))
|
||||
const userConfigDir = join(rootDir, "user-config")
|
||||
const homeDir = join(rootDir, "home")
|
||||
const workDir = join(homeDir, "work")
|
||||
const projectDir = join(workDir, "project")
|
||||
|
||||
tempDirs.push(rootDir)
|
||||
mkdirSync(userConfigDir, { recursive: true })
|
||||
mkdirSync(join(homeDir, ".opencode"), { recursive: true })
|
||||
mkdirSync(join(workDir, ".opencode"), { recursive: true })
|
||||
mkdirSync(join(projectDir, ".opencode"), { recursive: true })
|
||||
|
||||
writeFileSync(join(userConfigDir, "oh-my-openagent.jsonc"), "{}")
|
||||
writeFileSync(
|
||||
join(homeDir, ".opencode", "oh-my-openagent.jsonc"),
|
||||
JSON.stringify({ agents: { oracle: { model: "home/oracle" } } })
|
||||
)
|
||||
writeFileSync(
|
||||
join(workDir, ".opencode", "oh-my-openagent.jsonc"),
|
||||
JSON.stringify({ agents: { hephaestus: { model: "work/hephaestus" } } })
|
||||
)
|
||||
writeFileSync(
|
||||
join(projectDir, ".opencode", "oh-my-openagent.jsonc"),
|
||||
JSON.stringify({ agents: { sisyphus: { model: "project/sisyphus" } } })
|
||||
)
|
||||
|
||||
process.env.OPENCODE_CONFIG_DIR = userConfigDir
|
||||
process.env.HOME = homeDir
|
||||
|
||||
// when
|
||||
const { loadPluginConfig } = await importFreshPluginConfigModule()
|
||||
const config = loadPluginConfig(projectDir, {})
|
||||
|
||||
// then - each level contributes a non-conflicting field
|
||||
expect(config.agents?.oracle?.model).toBe("home/oracle")
|
||||
expect(config.agents?.hephaestus?.model).toBe("work/hephaestus")
|
||||
expect(config.agents?.sisyphus?.model).toBe("project/sisyphus")
|
||||
})
|
||||
|
||||
it("should preserve mcp_env_allowlist as user-only when ancestors set their own allowlists", async () => {
|
||||
// given
|
||||
const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-walk-allowlist-"))
|
||||
const userConfigDir = join(rootDir, "user-config")
|
||||
const homeDir = join(rootDir, "home")
|
||||
const workDir = join(homeDir, "work")
|
||||
const projectDir = join(workDir, "project")
|
||||
|
||||
tempDirs.push(rootDir)
|
||||
mkdirSync(userConfigDir, { recursive: true })
|
||||
mkdirSync(join(homeDir, ".opencode"), { recursive: true })
|
||||
mkdirSync(join(workDir, ".opencode"), { recursive: true })
|
||||
mkdirSync(join(projectDir, ".opencode"), { recursive: true })
|
||||
|
||||
writeFileSync(
|
||||
join(userConfigDir, "oh-my-openagent.jsonc"),
|
||||
JSON.stringify({ mcp_env_allowlist: ["USER_ONLY_TOKEN"] })
|
||||
)
|
||||
writeFileSync(
|
||||
join(homeDir, ".opencode", "oh-my-openagent.jsonc"),
|
||||
JSON.stringify({ mcp_env_allowlist: ["HOME_TOKEN"] })
|
||||
)
|
||||
writeFileSync(
|
||||
join(workDir, ".opencode", "oh-my-openagent.jsonc"),
|
||||
JSON.stringify({ mcp_env_allowlist: ["WORK_TOKEN"] })
|
||||
)
|
||||
writeFileSync(
|
||||
join(projectDir, ".opencode", "oh-my-openagent.jsonc"),
|
||||
JSON.stringify({ mcp_env_allowlist: ["PROJECT_TOKEN"] })
|
||||
)
|
||||
|
||||
process.env.OPENCODE_CONFIG_DIR = userConfigDir
|
||||
process.env.HOME = homeDir
|
||||
|
||||
// when
|
||||
const { loadPluginConfig } = await importFreshPluginConfigModule()
|
||||
const config = loadPluginConfig(projectDir, {})
|
||||
|
||||
// then - only the canonical user config can extend the allowlist
|
||||
expect(config.mcp_env_allowlist).toEqual(["USER_ONLY_TOKEN"])
|
||||
})
|
||||
|
||||
it("should stop walking at $HOME and ignore configs above it", async () => {
|
||||
// given
|
||||
const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-walk-stop-"))
|
||||
const userConfigDir = join(rootDir, "user-config")
|
||||
const aboveHomeDir = join(rootDir, "above-home")
|
||||
const homeDir = join(aboveHomeDir, "home")
|
||||
const projectDir = join(homeDir, "project")
|
||||
|
||||
tempDirs.push(rootDir)
|
||||
mkdirSync(userConfigDir, { recursive: true })
|
||||
mkdirSync(join(aboveHomeDir, ".opencode"), { recursive: true })
|
||||
mkdirSync(join(homeDir, ".opencode"), { recursive: true })
|
||||
mkdirSync(join(projectDir, ".opencode"), { recursive: true })
|
||||
|
||||
writeFileSync(join(userConfigDir, "oh-my-openagent.jsonc"), "{}")
|
||||
writeFileSync(
|
||||
join(aboveHomeDir, ".opencode", "oh-my-openagent.jsonc"),
|
||||
JSON.stringify({ agents: { oracle: { model: "above-home/leak" } } })
|
||||
)
|
||||
writeFileSync(
|
||||
join(homeDir, ".opencode", "oh-my-openagent.jsonc"),
|
||||
JSON.stringify({ agents: { hephaestus: { model: "home/wins" } } })
|
||||
)
|
||||
writeFileSync(join(projectDir, ".opencode", "oh-my-openagent.jsonc"), "{}")
|
||||
|
||||
process.env.OPENCODE_CONFIG_DIR = userConfigDir
|
||||
process.env.HOME = homeDir
|
||||
|
||||
// when
|
||||
const { loadPluginConfig } = await importFreshPluginConfigModule()
|
||||
const config = loadPluginConfig(projectDir, {})
|
||||
|
||||
// then - $HOME's config applies, but the directory above it does NOT
|
||||
expect(config.agents?.hephaestus?.model).toBe("home/wins")
|
||||
expect(config.agents?.oracle).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should not walk above the start directory when start is outside $HOME", async () => {
|
||||
// given
|
||||
const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-walk-outside-"))
|
||||
const userConfigDir = join(rootDir, "user-config")
|
||||
const homeDir = join(rootDir, "home")
|
||||
const outsideHomeRoot = join(rootDir, "outside-home")
|
||||
const projectDir = join(outsideHomeRoot, "proj")
|
||||
|
||||
tempDirs.push(rootDir)
|
||||
mkdirSync(userConfigDir, { recursive: true })
|
||||
mkdirSync(homeDir, { recursive: true })
|
||||
mkdirSync(join(outsideHomeRoot, ".opencode"), { recursive: true })
|
||||
mkdirSync(join(projectDir, ".opencode"), { recursive: true })
|
||||
|
||||
writeFileSync(join(userConfigDir, "oh-my-openagent.jsonc"), "{}")
|
||||
writeFileSync(
|
||||
join(outsideHomeRoot, ".opencode", "oh-my-openagent.jsonc"),
|
||||
JSON.stringify({ agents: { oracle: { model: "outside-home/leak" } } })
|
||||
)
|
||||
writeFileSync(
|
||||
join(projectDir, ".opencode", "oh-my-openagent.jsonc"),
|
||||
JSON.stringify({ agents: { hephaestus: { model: "project/wins" } } })
|
||||
)
|
||||
|
||||
process.env.OPENCODE_CONFIG_DIR = userConfigDir
|
||||
process.env.HOME = homeDir
|
||||
|
||||
// when
|
||||
const { loadPluginConfig } = await importFreshPluginConfigModule()
|
||||
const config = loadPluginConfig(projectDir, {})
|
||||
|
||||
// then - project loads, but the parent above it (outside $HOME) is not walked into
|
||||
expect(config.agents?.hephaestus?.model).toBe("project/wins")
|
||||
expect(config.agents?.oracle).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should merge git_master overrides across ancestors with closer winning", async () => {
|
||||
// given
|
||||
const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-walk-git-master-"))
|
||||
const userConfigDir = join(rootDir, "user-config")
|
||||
const homeDir = join(rootDir, "home")
|
||||
const workDir = join(homeDir, "work")
|
||||
const projectDir = join(workDir, "project")
|
||||
|
||||
tempDirs.push(rootDir)
|
||||
mkdirSync(userConfigDir, { recursive: true })
|
||||
mkdirSync(join(homeDir, ".opencode"), { recursive: true })
|
||||
mkdirSync(join(workDir, ".opencode"), { recursive: true })
|
||||
mkdirSync(join(projectDir, ".opencode"), { recursive: true })
|
||||
|
||||
writeFileSync(join(userConfigDir, "oh-my-openagent.jsonc"), "{}")
|
||||
writeFileSync(
|
||||
join(homeDir, ".opencode", "oh-my-openagent.jsonc"),
|
||||
JSON.stringify({
|
||||
git_master: {
|
||||
commit_footer: false,
|
||||
include_co_authored_by: false,
|
||||
git_env_prefix: "HOME=1",
|
||||
},
|
||||
})
|
||||
)
|
||||
writeFileSync(
|
||||
join(workDir, ".opencode", "oh-my-openagent.jsonc"),
|
||||
JSON.stringify({
|
||||
git_master: {
|
||||
include_co_authored_by: true,
|
||||
},
|
||||
})
|
||||
)
|
||||
writeFileSync(
|
||||
join(projectDir, ".opencode", "oh-my-openagent.jsonc"),
|
||||
JSON.stringify({
|
||||
git_master: {
|
||||
commit_footer: true,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
process.env.OPENCODE_CONFIG_DIR = userConfigDir
|
||||
process.env.HOME = homeDir
|
||||
|
||||
// when
|
||||
const { loadPluginConfig } = await importFreshPluginConfigModule()
|
||||
const config = loadPluginConfig(projectDir, {})
|
||||
|
||||
// then project's commit_footer wins, work's include_co_authored_by wins,
|
||||
// home's git_env_prefix is preserved since nobody else set it
|
||||
expect(config.git_master).toEqual({
|
||||
commit_footer: true,
|
||||
include_co_authored_by: true,
|
||||
git_env_prefix: "HOME=1",
|
||||
})
|
||||
})
|
||||
|
||||
it("should resolve agent_definitions relative to each ancestor's own .opencode directory", async () => {
|
||||
// given
|
||||
const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-walk-agent-defs-"))
|
||||
const userConfigDir = join(rootDir, "user-config")
|
||||
const homeDir = join(rootDir, "home")
|
||||
const workDir = join(homeDir, "work")
|
||||
const projectDir = join(workDir, "project")
|
||||
const workDefRelativePath = "./work-agent.md"
|
||||
const projectDefRelativePath = "./project-agent.md"
|
||||
|
||||
tempDirs.push(rootDir)
|
||||
mkdirSync(userConfigDir, { recursive: true })
|
||||
mkdirSync(join(workDir, ".opencode"), { recursive: true })
|
||||
mkdirSync(join(projectDir, ".opencode"), { recursive: true })
|
||||
|
||||
writeFileSync(join(userConfigDir, "oh-my-openagent.jsonc"), "{}")
|
||||
writeFileSync(
|
||||
join(workDir, ".opencode", "oh-my-openagent.jsonc"),
|
||||
JSON.stringify({ agent_definitions: [workDefRelativePath] })
|
||||
)
|
||||
writeFileSync(
|
||||
join(projectDir, ".opencode", "oh-my-openagent.jsonc"),
|
||||
JSON.stringify({ agent_definitions: [projectDefRelativePath] })
|
||||
)
|
||||
|
||||
process.env.OPENCODE_CONFIG_DIR = userConfigDir
|
||||
process.env.HOME = homeDir
|
||||
|
||||
// when
|
||||
const { loadPluginConfig } = await importFreshPluginConfigModule()
|
||||
const config = loadPluginConfig(projectDir, {})
|
||||
|
||||
// then each ancestor's relative path resolves against its own .opencode/
|
||||
expect(config.agent_definitions).toContain(join(realpathSync(workDir), ".opencode", "work-agent.md"))
|
||||
expect(config.agent_definitions).toContain(join(realpathSync(projectDir), ".opencode", "project-agent.md"))
|
||||
})
|
||||
|
||||
it("should migrate legacy basenames found in ancestor directories", async () => {
|
||||
// given
|
||||
const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-walk-legacy-"))
|
||||
const userConfigDir = join(rootDir, "user-config")
|
||||
const homeDir = join(rootDir, "home")
|
||||
const workDir = join(homeDir, "work")
|
||||
const projectDir = join(workDir, "project")
|
||||
const ancestorLegacyPath = join(workDir, ".opencode", "oh-my-opencode.jsonc")
|
||||
const ancestorCanonicalPath = join(workDir, ".opencode", "oh-my-openagent.jsonc")
|
||||
|
||||
tempDirs.push(rootDir)
|
||||
mkdirSync(userConfigDir, { recursive: true })
|
||||
mkdirSync(join(homeDir, ".opencode"), { recursive: true })
|
||||
mkdirSync(join(workDir, ".opencode"), { recursive: true })
|
||||
mkdirSync(join(projectDir, ".opencode"), { recursive: true })
|
||||
|
||||
writeFileSync(join(userConfigDir, "oh-my-openagent.jsonc"), "{}")
|
||||
writeFileSync(
|
||||
ancestorLegacyPath,
|
||||
JSON.stringify({ agents: { oracle: { model: "ancestor-legacy/model" } } })
|
||||
)
|
||||
|
||||
process.env.OPENCODE_CONFIG_DIR = userConfigDir
|
||||
process.env.HOME = homeDir
|
||||
|
||||
// when
|
||||
const { loadPluginConfig } = await importFreshPluginConfigModule()
|
||||
const config = loadPluginConfig(projectDir, {})
|
||||
|
||||
// then
|
||||
expect(existsSync(ancestorLegacyPath)).toBe(false)
|
||||
expect(existsSync(ancestorCanonicalPath)).toBe(true)
|
||||
expect(config.agents?.oracle?.model).toBe("ancestor-legacy/model")
|
||||
})
|
||||
})
|
||||
|
||||
+95
-53
@@ -1,19 +1,50 @@
|
||||
import * as fs from "fs";
|
||||
import { homedir } from "node:os";
|
||||
import * as path from "path";
|
||||
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config";
|
||||
import {
|
||||
log,
|
||||
containsPath,
|
||||
deepMerge,
|
||||
getOpenCodeConfigDir,
|
||||
addConfigLoadError,
|
||||
parseJsonc,
|
||||
detectPluginConfigFile,
|
||||
findProjectOpencodePluginConfigFiles,
|
||||
migrateConfigFile,
|
||||
resolveAgentDefinitionPaths,
|
||||
} from "./shared";
|
||||
import { migrateLegacyConfigFile } from "./shared/migrate-legacy-config-file";
|
||||
import { CONFIG_BASENAME, LEGACY_CONFIG_BASENAME } from "./shared/plugin-identity";
|
||||
|
||||
function resolveHomeDirectory(): string {
|
||||
// Read env vars directly to bypass os.homedir() caching. Bun caches the
|
||||
// first os.homedir() result, which means tests that set process.env.HOME
|
||||
// after import never see the new value. Production behaviour is preserved
|
||||
// because HOME (or USERPROFILE on Windows) is set by the OS at startup.
|
||||
return process.env.HOME ?? process.env.USERPROFILE ?? homedir()
|
||||
}
|
||||
|
||||
function resolveConfigPathAfterLegacyMigration(detectedPath: string): string {
|
||||
if (!path.basename(detectedPath).startsWith(LEGACY_CONFIG_BASENAME)) {
|
||||
return detectedPath
|
||||
}
|
||||
|
||||
const migrated = migrateLegacyConfigFile(detectedPath)
|
||||
const canonicalPath = path.join(
|
||||
path.dirname(detectedPath),
|
||||
`${CONFIG_BASENAME}${path.extname(detectedPath)}`,
|
||||
)
|
||||
|
||||
// Only switch to canonical path if migration succeeded OR canonical file already exists
|
||||
if (migrated || fs.existsSync(canonicalPath)) {
|
||||
return canonicalPath
|
||||
}
|
||||
|
||||
// Otherwise keep loading from the legacy path that was detected
|
||||
return detectedPath
|
||||
}
|
||||
|
||||
function loadExplicitGitMasterOverrides(configPath: string): Record<string, unknown> | undefined {
|
||||
try {
|
||||
if (!fs.existsSync(configPath)) {
|
||||
@@ -214,47 +245,39 @@ export function loadPluginConfig(
|
||||
}
|
||||
|
||||
// Auto-copy legacy config file to canonical name if needed
|
||||
if (userDetected.format !== "none" && path.basename(userDetected.path).startsWith(LEGACY_CONFIG_BASENAME)) {
|
||||
const migrated = migrateLegacyConfigFile(userDetected.path);
|
||||
const canonicalPath = path.join(
|
||||
path.dirname(userDetected.path),
|
||||
`${CONFIG_BASENAME}${path.extname(userDetected.path)}`
|
||||
);
|
||||
// Only switch to canonical path if migration succeeded OR canonical file already exists
|
||||
if (migrated || fs.existsSync(canonicalPath)) {
|
||||
userConfigPath = canonicalPath;
|
||||
}
|
||||
// Otherwise keep loading from the legacy path that was detected
|
||||
if (userDetected.format !== "none") {
|
||||
userConfigPath = resolveConfigPathAfterLegacyMigration(userConfigPath)
|
||||
}
|
||||
|
||||
// Project-level config path - prefer .jsonc over .json
|
||||
const projectBasePath = path.join(directory, ".opencode");
|
||||
const projectDetected = detectPluginConfigFile(projectBasePath);
|
||||
let projectConfigPath =
|
||||
projectDetected.format !== "none"
|
||||
? projectDetected.path
|
||||
: path.join(projectBasePath, `${CONFIG_BASENAME}.json`);
|
||||
// Pin the walk to $HOME only when the start directory is inside it. Outside
|
||||
// $HOME the walker would otherwise reach FS root and surface unrelated configs
|
||||
// in /tmp, /opt, etc.
|
||||
const homeDirectory = resolveHomeDirectory()
|
||||
const stopDirectory = containsPath(homeDirectory, directory) ? homeDirectory : directory
|
||||
const ancestorConfigPathsNearestFirst = findProjectOpencodePluginConfigFiles(
|
||||
directory,
|
||||
stopDirectory,
|
||||
)
|
||||
log("Walked ancestor plugin configs", {
|
||||
paths: ancestorConfigPathsNearestFirst,
|
||||
count: ancestorConfigPathsNearestFirst.length,
|
||||
stopDirectory,
|
||||
})
|
||||
|
||||
if (projectDetected.legacyPath) {
|
||||
log("Canonical plugin config detected alongside legacy config. Remove the legacy file to avoid confusion.", {
|
||||
canonicalPath: projectDetected.path,
|
||||
legacyPath: projectDetected.legacyPath,
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-copy legacy project config file to canonical name if needed
|
||||
if (projectDetected.format !== "none" && path.basename(projectDetected.path).startsWith(LEGACY_CONFIG_BASENAME)) {
|
||||
const projectMigrated = migrateLegacyConfigFile(projectDetected.path);
|
||||
const canonicalProjectPath = path.join(
|
||||
path.dirname(projectDetected.path),
|
||||
`${CONFIG_BASENAME}${path.extname(projectDetected.path)}`
|
||||
);
|
||||
// Only switch to canonical path if migration succeeded OR canonical file already exists
|
||||
if (projectMigrated || fs.existsSync(canonicalProjectPath)) {
|
||||
projectConfigPath = canonicalProjectPath;
|
||||
}
|
||||
// Otherwise keep loading from the legacy path that was detected
|
||||
}
|
||||
// Migrate any legacy basenames among ancestors and warn on dual-config presence
|
||||
const canonicalAncestorPathsNearestFirst = ancestorConfigPathsNearestFirst.map(
|
||||
(ancestorPath) => {
|
||||
const opencodeDir = path.dirname(ancestorPath)
|
||||
const ancestorDetected = detectPluginConfigFile(opencodeDir)
|
||||
if (ancestorDetected.legacyPath) {
|
||||
log("Canonical plugin config detected alongside legacy config. Remove the legacy file to avoid confusion.", {
|
||||
canonicalPath: ancestorDetected.path,
|
||||
legacyPath: ancestorDetected.legacyPath,
|
||||
})
|
||||
}
|
||||
return resolveConfigPathAfterLegacyMigration(ancestorPath)
|
||||
},
|
||||
)
|
||||
|
||||
// Load user config first (base). Parse empty config through Zod to apply field defaults.
|
||||
const userConfig = loadConfigFromPath(userConfigPath, ctx)
|
||||
@@ -271,34 +294,53 @@ export function loadPluginConfig(
|
||||
let config: OhMyOpenCodeConfig =
|
||||
userConfig ?? OhMyOpenCodeConfigSchema.parse({});
|
||||
|
||||
// Override with project config
|
||||
const canonicalAncestorPathsFarthestFirst = [...canonicalAncestorPathsNearestFirst].reverse()
|
||||
const defaultGitMaster = OhMyOpenCodeConfigSchema.parse({}).git_master
|
||||
const projectConfig = loadConfigFromPath(projectConfigPath, ctx);
|
||||
const projectGitMasterOverrides = loadExplicitGitMasterOverrides(projectConfigPath)
|
||||
const ancestorGitMasterOverridesFarthestFirst: Array<Record<string, unknown>> = []
|
||||
|
||||
if (projectConfig?.agent_definitions) {
|
||||
projectConfig.agent_definitions = resolveAgentDefinitionPaths(
|
||||
projectConfig.agent_definitions,
|
||||
projectBasePath,
|
||||
directory
|
||||
)
|
||||
for (const ancestorPath of canonicalAncestorPathsFarthestFirst) {
|
||||
const ancestorConfig = loadConfigFromPath(ancestorPath, ctx)
|
||||
const ancestorOverrides = loadExplicitGitMasterOverrides(ancestorPath)
|
||||
|
||||
if (ancestorConfig?.agent_definitions) {
|
||||
// Resolve relative paths against this ancestor's own .opencode/ base.
|
||||
const ancestorBasePath = path.dirname(ancestorPath)
|
||||
const ancestorDir = path.dirname(ancestorBasePath)
|
||||
ancestorConfig.agent_definitions = resolveAgentDefinitionPaths(
|
||||
ancestorConfig.agent_definitions,
|
||||
ancestorBasePath,
|
||||
ancestorDir,
|
||||
)
|
||||
}
|
||||
|
||||
if (ancestorConfig) {
|
||||
config = mergeConfigs(config, ancestorConfig)
|
||||
}
|
||||
|
||||
if (ancestorOverrides) {
|
||||
ancestorGitMasterOverridesFarthestFirst.push(ancestorOverrides)
|
||||
}
|
||||
}
|
||||
|
||||
if (projectConfig) {
|
||||
config = mergeConfigs(config, projectConfig);
|
||||
}
|
||||
|
||||
if (userGitMasterOverrides || projectGitMasterOverrides) {
|
||||
if (userGitMasterOverrides || ancestorGitMasterOverridesFarthestFirst.length > 0) {
|
||||
const mergedAncestorGitMaster: Record<string, unknown> = {}
|
||||
for (const override of ancestorGitMasterOverridesFarthestFirst) {
|
||||
Object.assign(mergedAncestorGitMaster, override)
|
||||
}
|
||||
config = {
|
||||
...config,
|
||||
git_master: {
|
||||
...defaultGitMaster,
|
||||
...(userGitMasterOverrides ?? {}),
|
||||
...(projectGitMasterOverrides ?? {}),
|
||||
...mergedAncestorGitMaster,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Security: mcp_env_allowlist remains user-only across the entire walk.
|
||||
// This prevents clone-and-load attacks where a malicious project (or any
|
||||
// walked ancestor) could extend the env var allowlist used during ${VAR}
|
||||
// expansion in .mcp.json files. See commit 316d2504 for context.
|
||||
config = {
|
||||
...config,
|
||||
mcp_env_allowlist: userConfig?.mcp_env_allowlist ?? [],
|
||||
|
||||
@@ -103,12 +103,12 @@ describe("buildPrometheusAgentConfig", () => {
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
test("accepts glm-5 from fallback chain", async () => {
|
||||
test("accepts glm-5.1 from fallback chain", async () => {
|
||||
const result = await buildPrometheusAgentConfig({
|
||||
configAgentPlan: undefined,
|
||||
pluginPrometheusOverride: undefined,
|
||||
userCategories: undefined,
|
||||
currentModel: "opencode-go/glm-5",
|
||||
currentModel: "opencode-go/glm-5.1",
|
||||
});
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -222,7 +222,7 @@ describe("createEventHandler - model fallback", () => {
|
||||
expect(promptCalls).toEqual([sessionID])
|
||||
expect(output.message["model"]).toMatchObject({
|
||||
providerID: "opencode-go",
|
||||
modelID: "kimi-k2.5",
|
||||
modelID: "kimi-k2.6",
|
||||
})
|
||||
expect(output.message["variant"]).toBeUndefined()
|
||||
})
|
||||
@@ -549,14 +549,14 @@ describe("createEventHandler - model fallback", () => {
|
||||
//#then - first fallback entry applied (no-op skip: claude-opus-4-7 matches current model after normalization)
|
||||
expect(first.message["model"]).toMatchObject({
|
||||
providerID: "opencode-go",
|
||||
modelID: "kimi-k2.5",
|
||||
modelID: "kimi-k2.6",
|
||||
})
|
||||
expect(first.message["variant"]).toBeUndefined()
|
||||
|
||||
//#when - second retry cycle
|
||||
const second = await triggerRetryCycle()
|
||||
|
||||
//#then - second fallback entry applied (chain advanced past opencode-go/kimi-k2.5)
|
||||
//#then - second fallback entry applied (chain advanced past opencode-go/kimi-k2.6)
|
||||
expect(second.message["model"]).toMatchObject({
|
||||
providerID: "kimi-for-coding",
|
||||
modelID: "k2p5",
|
||||
|
||||
@@ -6,7 +6,7 @@ import { tmpdir } from "node:os"
|
||||
import { dirname, join } from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
import { normalizeToolArgSchemas } from "./normalize-tool-arg-schemas"
|
||||
import { normalizeToolArgSchemas, sanitizeJsonSchema } from "./normalize-tool-arg-schemas"
|
||||
|
||||
const tempDirectories: string[] = []
|
||||
|
||||
@@ -95,3 +95,36 @@ describe("normalizeToolArgSchemas", () => {
|
||||
expect(afterQuery?.examples).toEqual(["issue 2314"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("sanitizeJsonSchema", () => {
|
||||
it("rewrites bare $ref values to $defs JSON pointers", () => {
|
||||
// given
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
new_encoding: { $ref: "Encoding" },
|
||||
existing_pointer: { $ref: "#/$defs/AlreadyValid" },
|
||||
},
|
||||
$defs: {
|
||||
Encoding: { type: "string" },
|
||||
AlreadyValid: { type: "string" },
|
||||
},
|
||||
}
|
||||
|
||||
// when
|
||||
const sanitized = sanitizeJsonSchema(schema)
|
||||
|
||||
// then
|
||||
expect(sanitized).toEqual({
|
||||
type: "object",
|
||||
properties: {
|
||||
new_encoding: { $ref: "#/$defs/Encoding" },
|
||||
existing_pointer: { $ref: "#/$defs/AlreadyValid" },
|
||||
},
|
||||
$defs: {
|
||||
Encoding: { type: "string" },
|
||||
AlreadyValid: { type: "string" },
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -47,6 +47,14 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function normalizeJsonSchemaRef(value: string): string {
|
||||
if (value.startsWith("#") || value.includes(":") || value.startsWith("/")) {
|
||||
return value
|
||||
}
|
||||
|
||||
return `#/$defs/${value}`
|
||||
}
|
||||
|
||||
export function sanitizeJsonSchema(value: unknown, depth = 0, isPropertyName = false): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => sanitizeJsonSchema(item, depth + 1, false))
|
||||
@@ -67,6 +75,11 @@ export function sanitizeJsonSchema(value: unknown, depth = 0, isPropertyName = f
|
||||
continue
|
||||
}
|
||||
|
||||
if (!isPropertyName && key === "$ref" && typeof nestedValue === "string") {
|
||||
sanitized[key] = normalizeJsonSchemaRef(nestedValue)
|
||||
continue
|
||||
}
|
||||
|
||||
const childIsPropertyName = key === "properties" && !isPropertyName
|
||||
sanitized[key] = sanitizeJsonSchema(nestedValue, depth + 1, childIsPropertyName)
|
||||
}
|
||||
|
||||
@@ -181,5 +181,14 @@ export function createToolExecuteAfterHandler(args: {
|
||||
}
|
||||
|
||||
await runToolExecuteAfterHooks()
|
||||
|
||||
// Cap excessively long error outputs that would flood the TUI with raw
|
||||
// stack traces or framework internals. Normal outputs are handled by the
|
||||
// tool-output-truncator hook for specific tools; this catch-all only fires
|
||||
// for outputs that still exceed a safe display length after all hooks.
|
||||
const MAX_ERROR_OUTPUT_CHARS = 3000
|
||||
if (typeof output.output === "string" && output.output.length > MAX_ERROR_OUTPUT_CHARS) {
|
||||
output.output = output.output.slice(0, MAX_ERROR_OUTPUT_CHARS) + "\n\n...(output truncated for display)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,6 +287,8 @@ export function createToolRegistry(args: {
|
||||
browserProvider: skillContext.browserProvider,
|
||||
teamModeEnabled: pluginConfig.team_mode?.enabled ?? false,
|
||||
nativeSkills: "skills" in ctx ? (ctx as { skills: SkillLoadOptions["nativeSkills"] }).skills : undefined,
|
||||
pluginsEnabled: pluginConfig.claude_code?.plugins ?? true,
|
||||
enabledPluginsOverride: pluginConfig.claude_code?.plugins_override,
|
||||
})
|
||||
|
||||
const taskSystemEnabled = isTaskSystemEnabled(pluginConfig)
|
||||
|
||||
@@ -214,6 +214,10 @@ describe("stripAgentListSortPrefix", () => {
|
||||
it("strips legacy zero-width sort prefixes baked into v3.14.0–v3.16.0 sessions", () => {
|
||||
expect(stripAgentListSortPrefix("\u200B\u200BHephaestus - Deep Agent")).toBe("Hephaestus - Deep Agent")
|
||||
})
|
||||
|
||||
it("strips leading and trailing wrapper characters after sort prefix removal", () => {
|
||||
expect(stripAgentListSortPrefix("\\Hephaestus - Deep Agent\\")).toBe("Hephaestus - Deep Agent")
|
||||
})
|
||||
})
|
||||
|
||||
describe("normalizeAgentForPrompt", () => {
|
||||
|
||||
@@ -28,13 +28,14 @@ export const AGENT_DISPLAY_NAMES: Record<string, string> = {
|
||||
|
||||
const INVISIBLE_AGENT_CHARACTERS_REGEX = /[\u200B\u200C\u200D\uFEFF]/g
|
||||
const VISIBLE_AGENT_LIST_SORT_PREFIX_REGEX = /^\d+\|/
|
||||
const AGENT_WRAPPER_CHARS_REGEX = /^[\\/"']+|[\\/"']+$/g
|
||||
|
||||
export function stripInvisibleAgentCharacters(agentName: string): string {
|
||||
return agentName.replace(INVISIBLE_AGENT_CHARACTERS_REGEX, "")
|
||||
}
|
||||
|
||||
export function stripAgentListSortPrefix(agentName: string): string {
|
||||
return stripInvisibleAgentCharacters(agentName).replace(VISIBLE_AGENT_LIST_SORT_PREFIX_REGEX, "")
|
||||
return stripInvisibleAgentCharacters(agentName).replace(VISIBLE_AGENT_LIST_SORT_PREFIX_REGEX, "").replace(AGENT_WRAPPER_CHARS_REGEX, "")
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,7 +36,7 @@ describe("resolveAgentVariant", () => {
|
||||
sisyphus: { category: "ultrabrain" },
|
||||
},
|
||||
categories: {
|
||||
ultrabrain: { model: "openai/gpt-5.4", variant: "xhigh" },
|
||||
ultrabrain: { model: "openai/gpt-5.5", variant: "xhigh" },
|
||||
},
|
||||
} as OhMyOpenCodeConfig
|
||||
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import type { ModelCapabilitiesSnapshotEntry } from "./types"
|
||||
|
||||
export const SUPPLEMENTAL_MODEL_CAPABILITIES: Record<string, ModelCapabilitiesSnapshotEntry> = {
|
||||
"kimi-k2.6": {
|
||||
id: "kimi-k2.6",
|
||||
family: "kimi",
|
||||
reasoning: true,
|
||||
temperature: true,
|
||||
toolCall: true,
|
||||
modalities: {
|
||||
input: ["text", "image", "video"],
|
||||
output: ["text"],
|
||||
},
|
||||
limit: {
|
||||
context: 262144,
|
||||
output: 262144,
|
||||
},
|
||||
},
|
||||
"gpt-5.5": {
|
||||
id: "gpt-5.5",
|
||||
family: "gpt",
|
||||
|
||||
@@ -129,4 +129,14 @@ describe("model-capability-aliases", () => {
|
||||
ruleID: "claude-thinking-legacy-alias",
|
||||
})
|
||||
})
|
||||
|
||||
test("treats claude-opus-4-6-thinking as canonical, not as a legacy alias", () => {
|
||||
const result = resolveModelIDAlias("claude-opus-4-6-thinking")
|
||||
|
||||
expect(result).toEqual({
|
||||
requestedModelID: "claude-opus-4-6-thinking",
|
||||
canonicalModelID: "claude-opus-4-6-thinking",
|
||||
source: "canonical",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,8 +53,8 @@ const EXACT_ALIAS_RULES_BY_MODEL: ReadonlyMap<string, ExactAliasRule> = new Map(
|
||||
const PATTERN_ALIAS_RULES: ReadonlyArray<PatternAliasRule> = [
|
||||
{
|
||||
ruleID: "claude-thinking-legacy-alias",
|
||||
description: "Normalizes legacy Claude Opus thinking suffixes (4-6, 4-7) to the canonical snapshot ID.",
|
||||
match: (normalizedModelID) => /^claude-opus-4-(?:6|7)-thinking$/.test(normalizedModelID),
|
||||
description: "Normalizes the legacy claude-opus-4-7-thinking id to the canonical snapshot ID.",
|
||||
match: (normalizedModelID) => /^claude-opus-4-7-thinking$/.test(normalizedModelID),
|
||||
canonicalize: () => "claude-opus-4-7",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -32,7 +32,7 @@ export const HEURISTIC_MODEL_FAMILY_REGISTRY: ReadonlyArray<HeuristicModelFamily
|
||||
family: "gpt-5",
|
||||
includes: ["gpt-5"],
|
||||
variants: ["low", "medium", "high", "xhigh"],
|
||||
reasoningEfforts: ["none", "minimal", "low", "medium", "high", "xhigh"],
|
||||
reasoningEfforts: ["none", "minimal", "low", "medium", "high", "xhigh", "max"],
|
||||
},
|
||||
{
|
||||
family: "gpt-legacy",
|
||||
|
||||
@@ -41,7 +41,7 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
|
||||
const second = sisyphus.fallbackChain[1]
|
||||
expect(second.providers).toEqual(["opencode-go", "vercel"])
|
||||
expect(second.model).toBe("kimi-k2.5")
|
||||
expect(second.model).toBe("kimi-k2.6")
|
||||
|
||||
const third = sisyphus.fallbackChain[2]
|
||||
expect(third.providers).toEqual(["kimi-for-coding"])
|
||||
@@ -72,27 +72,31 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
// then - fallbackChain exists with openai/gpt-5.4-mini-fast as first entry
|
||||
expect(librarian).toBeDefined()
|
||||
expect(librarian.fallbackChain).toBeArray()
|
||||
expect(librarian.fallbackChain).toHaveLength(5)
|
||||
expect(librarian.fallbackChain).toHaveLength(6)
|
||||
|
||||
const primary = librarian.fallbackChain[0]
|
||||
expect(primary.providers).toEqual(["openai"])
|
||||
expect(primary.model).toBe("gpt-5.4-mini-fast")
|
||||
|
||||
const second = librarian.fallbackChain[1]
|
||||
expect(second.providers[0]).toBe("opencode-go")
|
||||
expect(second.model).toBe("minimax-m2.7-highspeed")
|
||||
expect(second.providers).toContain("opencode-go")
|
||||
expect(second.model).toBe("qwen3.5-plus")
|
||||
|
||||
const tertiary = librarian.fallbackChain[2]
|
||||
expect(tertiary.providers[0]).toBe("opencode-go")
|
||||
expect(tertiary.model).toBe("minimax-m2.7")
|
||||
const third = librarian.fallbackChain[2]
|
||||
expect(third.providers).toEqual(["vercel"])
|
||||
expect(third.model).toBe("minimax-m2.7-highspeed")
|
||||
|
||||
const quaternary = librarian.fallbackChain[3]
|
||||
expect(quaternary.providers).toContain("anthropic")
|
||||
expect(quaternary.model).toBe("claude-haiku-4-5")
|
||||
expect(quaternary.providers).toContain("opencode-go")
|
||||
expect(quaternary.model).toBe("minimax-m2.7")
|
||||
|
||||
const fifth = librarian.fallbackChain[4]
|
||||
expect(fifth.providers).toContain("openai")
|
||||
expect(fifth.model).toBe("gpt-5.4-nano")
|
||||
const quinary = librarian.fallbackChain[4]
|
||||
expect(quinary.providers).toContain("anthropic")
|
||||
expect(quinary.model).toBe("claude-haiku-4-5")
|
||||
|
||||
const sixth = librarian.fallbackChain[5]
|
||||
expect(sixth.providers).toContain("openai")
|
||||
expect(sixth.model).toBe("gpt-5.4-nano")
|
||||
})
|
||||
|
||||
test("explore has valid fallbackChain with openai/gpt-5.4-mini-fast as primary", () => {
|
||||
@@ -102,7 +106,7 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
// when - accessing explore requirement
|
||||
expect(explore).toBeDefined()
|
||||
expect(explore.fallbackChain).toBeArray()
|
||||
expect(explore.fallbackChain).toHaveLength(5)
|
||||
expect(explore.fallbackChain).toHaveLength(6)
|
||||
|
||||
const primary = explore.fallbackChain[0]
|
||||
expect(primary.providers).toEqual(["openai"])
|
||||
@@ -110,19 +114,23 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
|
||||
const secondary = explore.fallbackChain[1]
|
||||
expect(secondary.providers).toContain("opencode-go")
|
||||
expect(secondary.model).toBe("minimax-m2.7-highspeed")
|
||||
expect(secondary.model).toBe("qwen3.5-plus")
|
||||
|
||||
const tertiary = explore.fallbackChain[2]
|
||||
expect(tertiary.providers).toContain("opencode-go")
|
||||
expect(tertiary.model).toBe("minimax-m2.7")
|
||||
const third = explore.fallbackChain[2]
|
||||
expect(third.providers).toEqual(["vercel"])
|
||||
expect(third.model).toBe("minimax-m2.7-highspeed")
|
||||
|
||||
const quaternary = explore.fallbackChain[3]
|
||||
expect(quaternary.providers).toContain("anthropic")
|
||||
expect(quaternary.model).toBe("claude-haiku-4-5")
|
||||
expect(quaternary.providers).toContain("opencode-go")
|
||||
expect(quaternary.model).toBe("minimax-m2.7")
|
||||
|
||||
const fifth = explore.fallbackChain[4]
|
||||
expect(fifth.providers).toContain("openai")
|
||||
expect(fifth.model).toBe("gpt-5.4-nano")
|
||||
const quinary = explore.fallbackChain[4]
|
||||
expect(quinary.providers).toContain("anthropic")
|
||||
expect(quinary.model).toBe("claude-haiku-4-5")
|
||||
|
||||
const sixth = explore.fallbackChain[5]
|
||||
expect(sixth.providers).toContain("openai")
|
||||
expect(sixth.model).toBe("gpt-5.4-nano")
|
||||
})
|
||||
|
||||
test("multimodal-looker has valid fallbackChain with gpt-5.5 as primary", () => {
|
||||
@@ -130,7 +138,7 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
const multimodalLooker = AGENT_MODEL_REQUIREMENTS["multimodal-looker"]
|
||||
|
||||
// when - accessing multimodal-looker requirement
|
||||
// then - fallbackChain: gpt-5.5 -> opencode-go/kimi-k2.5 -> glm-4.6v -> gpt-5-nano
|
||||
// then - fallbackChain: gpt-5.5 -> opencode-go/kimi-k2.6 -> glm-4.6v -> gpt-5-nano
|
||||
expect(multimodalLooker).toBeDefined()
|
||||
expect(multimodalLooker.fallbackChain).toBeArray()
|
||||
expect(multimodalLooker.fallbackChain).toHaveLength(4)
|
||||
@@ -142,7 +150,7 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
|
||||
const secondary = multimodalLooker.fallbackChain[1]
|
||||
expect(secondary.providers).toEqual(["opencode-go", "vercel"])
|
||||
expect(secondary.model).toBe("kimi-k2.5")
|
||||
expect(secondary.model).toBe("kimi-k2.6")
|
||||
|
||||
const tertiary = multimodalLooker.fallbackChain[2]
|
||||
expect(tertiary.model).toBe("glm-4.6v")
|
||||
@@ -222,7 +230,7 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
expect(primary.providers[0]).toBe("anthropic")
|
||||
|
||||
const secondary = atlas.fallbackChain[1]
|
||||
expect(secondary.model).toBe("kimi-k2.5")
|
||||
expect(secondary.model).toBe("kimi-k2.6")
|
||||
expect(secondary.providers[0]).toBe("opencode-go")
|
||||
|
||||
const tertiary = atlas.fallbackChain[2]
|
||||
@@ -345,7 +353,7 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
|
||||
const visualEngineering = CATEGORY_MODEL_REQUIREMENTS["visual-engineering"]
|
||||
|
||||
// when - accessing visual-engineering requirement
|
||||
// then - fallbackChain: gemini-3.1-pro(high) → glm-5 → opus-4-6(max) → opencode-go/glm-5 → k2p5
|
||||
// then - fallbackChain: gemini-3.1-pro(high) → glm-5 → opus-4-6(max) → opencode-go/glm-5.1 → k2p5
|
||||
expect(visualEngineering).toBeDefined()
|
||||
expect(visualEngineering.fallbackChain).toBeArray()
|
||||
expect(visualEngineering.fallbackChain).toHaveLength(5)
|
||||
@@ -365,7 +373,7 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
|
||||
|
||||
const fourth = visualEngineering.fallbackChain[3]
|
||||
expect(fourth.providers[0]).toBe("opencode-go")
|
||||
expect(fourth.model).toBe("glm-5")
|
||||
expect(fourth.model).toBe("glm-5.1")
|
||||
|
||||
const fifth = visualEngineering.fallbackChain[4]
|
||||
expect(fifth.providers[0]).toBe("kimi-for-coding")
|
||||
@@ -458,7 +466,7 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
|
||||
expect(primary.providers[0]).toBe("google")
|
||||
|
||||
const second = writing.fallbackChain[1]
|
||||
expect(second.model).toBe("kimi-k2.5")
|
||||
expect(second.model).toBe("kimi-k2.6")
|
||||
expect(second.providers[0]).toBe("opencode-go")
|
||||
|
||||
const third = writing.fallbackChain[2]
|
||||
|
||||
@@ -25,7 +25,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
model: "claude-opus-4-7",
|
||||
variant: "max",
|
||||
},
|
||||
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.5" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.6" },
|
||||
{ providers: ["kimi-for-coding"], model: "k2p5" },
|
||||
{
|
||||
providers: [
|
||||
@@ -72,13 +72,14 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
model: "claude-opus-4-7",
|
||||
variant: "max",
|
||||
},
|
||||
{ providers: ["opencode-go", "vercel"], model: "glm-5" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
|
||||
],
|
||||
},
|
||||
librarian: {
|
||||
fallbackChain: [
|
||||
{ providers: ["openai"], model: "gpt-5.4-mini-fast" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "minimax-m2.7-highspeed" },
|
||||
{ providers: ["opencode-go"], model: "qwen3.5-plus" },
|
||||
{ providers: ["vercel"], model: "minimax-m2.7-highspeed" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "minimax-m2.7" },
|
||||
{ providers: ["anthropic", "opencode", "vercel"], model: "claude-haiku-4-5" },
|
||||
{ providers: ["openai", "opencode", "vercel"], model: "gpt-5.4-nano" },
|
||||
@@ -87,7 +88,8 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
explore: {
|
||||
fallbackChain: [
|
||||
{ providers: ["openai"], model: "gpt-5.4-mini-fast" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "minimax-m2.7-highspeed" },
|
||||
{ providers: ["opencode-go"], model: "qwen3.5-plus" },
|
||||
{ providers: ["vercel"], model: "minimax-m2.7-highspeed" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "minimax-m2.7" },
|
||||
{ providers: ["anthropic", "opencode", "vercel"], model: "claude-haiku-4-5" },
|
||||
{ providers: ["openai", "opencode", "vercel"], model: "gpt-5.4-nano" },
|
||||
@@ -96,7 +98,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
"multimodal-looker": {
|
||||
fallbackChain: [
|
||||
{ providers: ["openai", "opencode", "vercel"], model: "gpt-5.5", variant: "medium" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.5" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.6" },
|
||||
{ providers: ["zai-coding-plan", "vercel"], model: "glm-4.6v" },
|
||||
{ providers: ["openai", "github-copilot", "opencode", "vercel"], model: "gpt-5-nano" },
|
||||
],
|
||||
@@ -113,7 +115,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
model: "gpt-5.5",
|
||||
variant: "high",
|
||||
},
|
||||
{ providers: ["opencode-go", "vercel"], model: "glm-5" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
|
||||
{
|
||||
providers: ["google", "github-copilot", "opencode", "vercel"],
|
||||
model: "gemini-3.1-pro",
|
||||
@@ -132,7 +134,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
model: "gpt-5.5",
|
||||
variant: "high",
|
||||
},
|
||||
{ providers: ["opencode-go", "vercel"], model: "glm-5" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
|
||||
{ providers: ["kimi-for-coding"], model: "k2p5" },
|
||||
],
|
||||
},
|
||||
@@ -153,13 +155,13 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
model: "gemini-3.1-pro",
|
||||
variant: "high",
|
||||
},
|
||||
{ providers: ["opencode-go", "vercel"], model: "glm-5" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
|
||||
],
|
||||
},
|
||||
atlas: {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "github-copilot", "opencode", "vercel"], model: "claude-sonnet-4-6" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.5" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.6" },
|
||||
{
|
||||
providers: ["openai", "github-copilot", "opencode", "vercel"],
|
||||
model: "gpt-5.5",
|
||||
@@ -171,7 +173,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
"sisyphus-junior": {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "github-copilot", "opencode", "vercel"], model: "claude-sonnet-4-6" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.5" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.6" },
|
||||
{
|
||||
providers: ["openai", "github-copilot", "opencode", "vercel"],
|
||||
model: "gpt-5.5",
|
||||
@@ -197,7 +199,7 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
model: "claude-opus-4-7",
|
||||
variant: "max",
|
||||
},
|
||||
{ providers: ["opencode-go", "vercel"], model: "glm-5" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
|
||||
{ providers: ["kimi-for-coding"], model: "k2p5" },
|
||||
],
|
||||
},
|
||||
@@ -218,7 +220,7 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
model: "claude-opus-4-7",
|
||||
variant: "max",
|
||||
},
|
||||
{ providers: ["opencode-go", "vercel"], model: "glm-5" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
|
||||
],
|
||||
},
|
||||
deep: {
|
||||
@@ -284,7 +286,7 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
model: "gpt-5.3-codex",
|
||||
variant: "medium",
|
||||
},
|
||||
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.5" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.6" },
|
||||
{
|
||||
providers: ["google", "github-copilot", "opencode", "vercel"],
|
||||
model: "gemini-3-flash",
|
||||
@@ -306,7 +308,7 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
},
|
||||
{ providers: ["zai-coding-plan", "opencode", "vercel"], model: "glm-5" },
|
||||
{ providers: ["kimi-for-coding"], model: "k2p5" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "glm-5" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
|
||||
{ providers: ["opencode", "vercel"], model: "kimi-k2.5" },
|
||||
{
|
||||
providers: [
|
||||
@@ -328,7 +330,7 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
providers: ["google", "github-copilot", "opencode", "vercel"],
|
||||
model: "gemini-3-flash",
|
||||
},
|
||||
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.5" },
|
||||
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.6" },
|
||||
{
|
||||
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
|
||||
model: "claude-sonnet-4-6",
|
||||
|
||||
@@ -32,10 +32,10 @@ export type ModelSettingsCompatibilityChange = {
|
||||
from: string
|
||||
to?: string
|
||||
reason:
|
||||
| "unsupported-by-model-family"
|
||||
| "unknown-model-family"
|
||||
| "unsupported-by-model-metadata"
|
||||
| "max-output-limit"
|
||||
| "unsupported-by-model-family"
|
||||
| "unknown-model-family"
|
||||
| "unsupported-by-model-metadata"
|
||||
| "max-output-limit"
|
||||
}
|
||||
|
||||
export type ModelSettingsCompatibilityResult = {
|
||||
@@ -49,7 +49,7 @@ export type ModelSettingsCompatibilityResult = {
|
||||
}
|
||||
|
||||
const VARIANT_LADDER = ["low", "medium", "high", "xhigh", "max"]
|
||||
const REASONING_LADDER = ["none", "minimal", "low", "medium", "high", "xhigh"]
|
||||
const REASONING_LADDER = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]
|
||||
|
||||
function downgradeWithinLadder(value: string, allowed: string[], ladder: string[]): string | undefined {
|
||||
const requestedIndex = ladder.indexOf(value)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
import { mkdirSync, realpathSync, rmSync } from "node:fs"
|
||||
import { mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
|
||||
@@ -121,4 +121,94 @@ describe("project-discovery-dirs", () => {
|
||||
expect(directories).toEqual([canonicalPath(join(projectDir, ".opencode", "skills"))])
|
||||
})
|
||||
|
||||
it("#given nested .opencode plugin config files #when finding plugin config files #then returns nearest-first canonical paths", async () => {
|
||||
// given
|
||||
const grandparentDir = join(TEST_DIR, "grandparent")
|
||||
const parentDir = join(grandparentDir, "parent")
|
||||
const projectDir = join(parentDir, "project")
|
||||
mkdirSync(join(grandparentDir, ".opencode"), { recursive: true })
|
||||
mkdirSync(join(parentDir, ".opencode"), { recursive: true })
|
||||
mkdirSync(join(projectDir, ".opencode"), { recursive: true })
|
||||
writeFileSync(join(grandparentDir, ".opencode", "oh-my-openagent.jsonc"), "{}")
|
||||
writeFileSync(join(parentDir, ".opencode", "oh-my-openagent.jsonc"), "{}")
|
||||
writeFileSync(join(projectDir, ".opencode", "oh-my-openagent.jsonc"), "{}")
|
||||
|
||||
const { clearPluginConfigFileDetectionCache } = await import("./jsonc-parser")
|
||||
clearPluginConfigFileDetectionCache()
|
||||
const { findProjectOpencodePluginConfigFiles } = await import("./project-discovery-dirs")
|
||||
|
||||
// when
|
||||
const paths = findProjectOpencodePluginConfigFiles(projectDir, TEST_DIR)
|
||||
|
||||
// then
|
||||
expect(paths).toEqual([
|
||||
canonicalPath(join(projectDir, ".opencode", "oh-my-openagent.jsonc")),
|
||||
canonicalPath(join(parentDir, ".opencode", "oh-my-openagent.jsonc")),
|
||||
canonicalPath(join(grandparentDir, ".opencode", "oh-my-openagent.jsonc")),
|
||||
])
|
||||
})
|
||||
|
||||
it("#given a stop directory #when finding plugin config files #then walking halts at the stop boundary inclusive", async () => {
|
||||
// given
|
||||
const stopDir = join(TEST_DIR, "stop")
|
||||
const childDir = join(stopDir, "child")
|
||||
mkdirSync(join(TEST_DIR, ".opencode"), { recursive: true })
|
||||
mkdirSync(join(stopDir, ".opencode"), { recursive: true })
|
||||
mkdirSync(join(childDir, ".opencode"), { recursive: true })
|
||||
writeFileSync(join(TEST_DIR, ".opencode", "oh-my-openagent.jsonc"), "{}")
|
||||
writeFileSync(join(stopDir, ".opencode", "oh-my-openagent.jsonc"), "{}")
|
||||
writeFileSync(join(childDir, ".opencode", "oh-my-openagent.jsonc"), "{}")
|
||||
|
||||
const { clearPluginConfigFileDetectionCache } = await import("./jsonc-parser")
|
||||
clearPluginConfigFileDetectionCache()
|
||||
const { findProjectOpencodePluginConfigFiles } = await import("./project-discovery-dirs")
|
||||
|
||||
// when
|
||||
const paths = findProjectOpencodePluginConfigFiles(childDir, stopDir)
|
||||
|
||||
// then
|
||||
expect(paths).toEqual([
|
||||
canonicalPath(join(childDir, ".opencode", "oh-my-openagent.jsonc")),
|
||||
canonicalPath(join(stopDir, ".opencode", "oh-my-openagent.jsonc")),
|
||||
])
|
||||
})
|
||||
|
||||
it("#given a legacy basename in an ancestor #when finding plugin config files #then detection picks up the legacy path", async () => {
|
||||
// given
|
||||
const projectDir = join(TEST_DIR, "project")
|
||||
mkdirSync(join(TEST_DIR, ".opencode"), { recursive: true })
|
||||
mkdirSync(join(projectDir, ".opencode"), { recursive: true })
|
||||
writeFileSync(join(TEST_DIR, ".opencode", "oh-my-opencode.jsonc"), "{}")
|
||||
writeFileSync(join(projectDir, ".opencode", "oh-my-openagent.jsonc"), "{}")
|
||||
|
||||
const { clearPluginConfigFileDetectionCache } = await import("./jsonc-parser")
|
||||
clearPluginConfigFileDetectionCache()
|
||||
const { findProjectOpencodePluginConfigFiles } = await import("./project-discovery-dirs")
|
||||
|
||||
// when
|
||||
const paths = findProjectOpencodePluginConfigFiles(projectDir, TEST_DIR)
|
||||
|
||||
// then
|
||||
expect(paths).toEqual([
|
||||
canonicalPath(join(projectDir, ".opencode", "oh-my-openagent.jsonc")),
|
||||
canonicalPath(join(TEST_DIR, ".opencode", "oh-my-opencode.jsonc")),
|
||||
])
|
||||
})
|
||||
|
||||
it("#given no .opencode directories along the walk #when finding plugin config files #then returns an empty list", async () => {
|
||||
// given
|
||||
const projectDir = join(TEST_DIR, "project", "deep")
|
||||
mkdirSync(projectDir, { recursive: true })
|
||||
|
||||
const { clearPluginConfigFileDetectionCache } = await import("./jsonc-parser")
|
||||
clearPluginConfigFileDetectionCache()
|
||||
const { findProjectOpencodePluginConfigFiles } = await import("./project-discovery-dirs")
|
||||
|
||||
// when
|
||||
const paths = findProjectOpencodePluginConfigFiles(projectDir, TEST_DIR)
|
||||
|
||||
// then
|
||||
expect(paths).toEqual([])
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -2,6 +2,8 @@ import { execFileSync } from "node:child_process"
|
||||
import { existsSync, realpathSync } from "node:fs"
|
||||
import { dirname, join, resolve } from "node:path"
|
||||
|
||||
import { detectPluginConfigFile } from "./jsonc-parser"
|
||||
|
||||
const worktreePathCache = new Map<string, string | undefined>()
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
@@ -114,3 +116,35 @@ export function findProjectOpencodeCommandDirs(startDirectory: string, stopDirec
|
||||
stopDirectory ?? detectWorktreePath(startDirectory),
|
||||
)
|
||||
}
|
||||
|
||||
export function findProjectOpencodePluginConfigFiles(
|
||||
startDirectory: string,
|
||||
stopDirectory?: string,
|
||||
): string[] {
|
||||
const paths: string[] = []
|
||||
const seen = new Set<string>()
|
||||
let currentDirectory = normalizePath(startDirectory)
|
||||
const resolvedStopDirectory = stopDirectory ? normalizePath(stopDirectory) : undefined
|
||||
|
||||
while (true) {
|
||||
const opencodeDirectory = join(currentDirectory, ".opencode")
|
||||
if (existsSync(opencodeDirectory)) {
|
||||
const detected = detectPluginConfigFile(opencodeDirectory)
|
||||
if (detected.format !== "none" && !seen.has(detected.path)) {
|
||||
seen.add(detected.path)
|
||||
paths.push(detected.path)
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedStopDirectory === currentDirectory) {
|
||||
return paths
|
||||
}
|
||||
|
||||
const parentDirectory = dirname(currentDirectory)
|
||||
if (parentDirectory === currentDirectory) {
|
||||
return paths
|
||||
}
|
||||
|
||||
currentDirectory = normalizePath(parentDirectory)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
import { AST_GREP_REPLACE_DESCRIPTION, AST_GREP_SEARCH_DESCRIPTION } from "./tool-descriptions"
|
||||
|
||||
const runSgMock = mock(async () => ({
|
||||
matches: [],
|
||||
totalMatches: 0,
|
||||
truncated: false,
|
||||
}))
|
||||
|
||||
mock.module("./cli", () => ({
|
||||
runSg: runSgMock,
|
||||
}))
|
||||
|
||||
import { createAstGrepTools } from "./tools"
|
||||
|
||||
describe("createAstGrepTools", () => {
|
||||
beforeEach(() => {
|
||||
runSgMock.mockClear()
|
||||
})
|
||||
|
||||
it("#given the production tool factory #when creating tools #then exposes shared ast-grep descriptions", () => {
|
||||
// given / when
|
||||
const tools = createAstGrepTools({ directory: "/repo" } as never)
|
||||
|
||||
// then
|
||||
expect(tools.ast_grep_search.description).toBe(AST_GREP_SEARCH_DESCRIPTION)
|
||||
expect(tools.ast_grep_replace.description).toBe(AST_GREP_REPLACE_DESCRIPTION)
|
||||
expect(tools.ast_grep_search.description).toContain("NOT regex")
|
||||
})
|
||||
|
||||
it("#given empty search results from a regex-shaped pattern #when executing #then appends the pattern hint", async () => {
|
||||
// given
|
||||
const tools = createAstGrepTools({ directory: "/repo" } as never)
|
||||
|
||||
// when
|
||||
const output = await tools.ast_grep_search.execute(
|
||||
{ pattern: "foo|bar", lang: "typescript" },
|
||||
{},
|
||||
)
|
||||
|
||||
// then
|
||||
expect(output).toContain("No matches found")
|
||||
expect(output).toContain("alternation")
|
||||
expect(output).toContain("grep")
|
||||
expect(runSgMock).toHaveBeenCalledWith({
|
||||
pattern: "foo|bar",
|
||||
lang: "typescript",
|
||||
paths: ["/repo"],
|
||||
globs: undefined,
|
||||
context: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
+10
-35
@@ -3,6 +3,12 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||
import { CLI_LANGUAGES } from "./constants"
|
||||
import { runSg } from "./cli"
|
||||
import { formatSearchResult, formatReplaceResult } from "./result-formatter"
|
||||
import { getPatternHint } from "./pattern-hints"
|
||||
import {
|
||||
AST_GREP_REPLACE_DESCRIPTION,
|
||||
AST_GREP_SEARCH_DESCRIPTION,
|
||||
AST_GREP_SEARCH_PATTERN_PARAM,
|
||||
} from "./tool-descriptions"
|
||||
import type { CliLanguage } from "./types"
|
||||
|
||||
async function showOutputToUser(context: unknown, output: string): Promise<void> {
|
||||
@@ -12,39 +18,11 @@ async function showOutputToUser(context: unknown, output: string): Promise<void>
|
||||
await ctx.metadata?.({ metadata: { output } })
|
||||
}
|
||||
|
||||
function getEmptyResultHint(pattern: string, lang: CliLanguage): string | null {
|
||||
const src = pattern.trim()
|
||||
|
||||
if (lang === "python") {
|
||||
if (src.startsWith("class ") && src.endsWith(":")) {
|
||||
const withoutColon = src.slice(0, -1)
|
||||
return `Hint: Remove trailing colon. Try: "${withoutColon}"`
|
||||
}
|
||||
if ((src.startsWith("def ") || src.startsWith("async def ")) && src.endsWith(":")) {
|
||||
const withoutColon = src.slice(0, -1)
|
||||
return `Hint: Remove trailing colon. Try: "${withoutColon}"`
|
||||
}
|
||||
}
|
||||
|
||||
if (["javascript", "typescript", "tsx"].includes(lang)) {
|
||||
if (/^(export\s+)?(async\s+)?function\s+\$[A-Z_]+\s*$/i.test(src)) {
|
||||
return `Hint: Function patterns need params and body. Try "function $NAME($$$) { $$$ }"`
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function createAstGrepTools(ctx: PluginInput): Record<string, ToolDefinition> {
|
||||
const ast_grep_search: ToolDefinition = tool({
|
||||
description:
|
||||
"Search code patterns across filesystem using AST-aware matching. Supports 25 languages. " +
|
||||
"Use meta-variables: $VAR (single node), $$$ (multiple nodes). " +
|
||||
"IMPORTANT: Patterns must be complete AST nodes (valid code). " +
|
||||
"For functions, include params and body: 'export async function $NAME($$$) { $$$ }' not 'export async function $NAME'. " +
|
||||
"Examples: 'console.log($MSG)', 'def $FUNC($$$):', 'async function $NAME($$$)'",
|
||||
description: AST_GREP_SEARCH_DESCRIPTION,
|
||||
args: {
|
||||
pattern: tool.schema.string().describe("AST pattern with meta-variables ($VAR, $$$). Must be complete AST node."),
|
||||
pattern: tool.schema.string().describe(AST_GREP_SEARCH_PATTERN_PARAM),
|
||||
lang: tool.schema.enum(CLI_LANGUAGES).describe("Target language"),
|
||||
paths: tool.schema.array(tool.schema.string()).optional().describe("Paths to search (default: ['.'])"),
|
||||
globs: tool.schema.array(tool.schema.string()).optional().describe("Include/exclude globs (prefix ! to exclude)"),
|
||||
@@ -63,7 +41,7 @@ export function createAstGrepTools(ctx: PluginInput): Record<string, ToolDefinit
|
||||
let output = formatSearchResult(result)
|
||||
|
||||
if (result.matches.length === 0 && !result.error) {
|
||||
const hint = getEmptyResultHint(args.pattern, args.lang as CliLanguage)
|
||||
const hint = getPatternHint(args.pattern, args.lang as CliLanguage)
|
||||
if (hint) {
|
||||
output += `\n\n${hint}`
|
||||
}
|
||||
@@ -80,10 +58,7 @@ export function createAstGrepTools(ctx: PluginInput): Record<string, ToolDefinit
|
||||
})
|
||||
|
||||
const ast_grep_replace: ToolDefinition = tool({
|
||||
description:
|
||||
"Replace code patterns across filesystem with AST-aware rewriting. " +
|
||||
"Dry-run by default. Use meta-variables in rewrite to preserve matched content. " +
|
||||
"Example: pattern='console.log($MSG)' rewrite='logger.info($MSG)'",
|
||||
description: AST_GREP_REPLACE_DESCRIPTION,
|
||||
args: {
|
||||
pattern: tool.schema.string().describe("AST pattern to match"),
|
||||
rewrite: tool.schema.string().describe("Replacement pattern (can use $VAR from pattern)"),
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { describe, test, expect, mock } from "bun:test"
|
||||
mock.module("../../shared/frontmatter", () => ({
|
||||
parseFrontmatter: () => ({ frontmatter: {}, content: "" }),
|
||||
}))
|
||||
mock.module("js-yaml", () => ({
|
||||
load: () => ({}),
|
||||
}))
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { executeBackground } from "./background-executor"
|
||||
@@ -100,6 +106,35 @@ describe("executeBackground", () => {
|
||||
expect(launchArgs.fallbackChain).toEqual(fallbackChain)
|
||||
})
|
||||
|
||||
test("sanitizes subagent_type before passing to background manager launch", async () => {
|
||||
//#given
|
||||
const wrappedArgs = {
|
||||
...testArgs,
|
||||
subagent_type: "\\hephaestus\\",
|
||||
}
|
||||
launchMock.mockResolvedValueOnce({
|
||||
id: "test-task-id",
|
||||
sessionId: "sub-session",
|
||||
description: "Test task",
|
||||
agent: "hephaestus",
|
||||
status: "pending",
|
||||
})
|
||||
|
||||
//#when
|
||||
await executeBackground(wrappedArgs, testContext, mockManager, mockClient)
|
||||
|
||||
//#then
|
||||
const latestCall = [...launchMock.mock.calls].pop()
|
||||
if (!latestCall) {
|
||||
throw new Error("Expected background manager launch to be called")
|
||||
}
|
||||
const launchArgs = latestCall[0]
|
||||
if (!launchArgs) {
|
||||
throw new Error("Expected launch arguments")
|
||||
}
|
||||
expect(launchArgs.agent).toBe("hephaestus")
|
||||
})
|
||||
|
||||
test("keeps launched background task alive when parent aborts before session id resolves", async () => {
|
||||
//#given - parent abort after launch should stop waiting, not fail the background task
|
||||
const abortController = new AbortController()
|
||||
|
||||
@@ -8,6 +8,7 @@ import { resolveMessageContext } from "../../features/hook-message-injector"
|
||||
import { getSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { getMessageDir } from "./message-dir"
|
||||
import { getSessionTools } from "../../shared/session-tools-store"
|
||||
import { sanitizeSubagentType } from "../delegate-task/subagent-discovery"
|
||||
|
||||
export async function executeBackground(
|
||||
args: CallOmoAgentArgs,
|
||||
@@ -47,7 +48,7 @@ export async function executeBackground(
|
||||
const task = await manager.launch({
|
||||
description: args.description,
|
||||
prompt: args.prompt,
|
||||
agent: args.subagent_type,
|
||||
agent: sanitizeSubagentType(args.subagent_type),
|
||||
parentSessionId: toolContext.sessionID,
|
||||
parentMessageId: toolContext.messageID,
|
||||
parentAgent,
|
||||
|
||||
@@ -136,6 +136,19 @@ describe("createCallOmoAgent", () => {
|
||||
})
|
||||
|
||||
describe("dynamic custom agent resolution", () => {
|
||||
test("should reject missing subagent_type without throwing", async () => {
|
||||
const mockCtx = createMockCtx(DEFAULT_AGENTS)
|
||||
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, [])
|
||||
const executeFunc = toolDef.execute as Function
|
||||
|
||||
const result = await executeFunc(
|
||||
{ description: "Test", prompt: "Fix bug", run_in_background: true },
|
||||
toolCtx
|
||||
)
|
||||
|
||||
expect(result).toContain("subagent_type is required")
|
||||
})
|
||||
|
||||
test("should accept a custom agent returned by client.app.agents()", async () => {
|
||||
const agents = [...DEFAULT_AGENTS, { name: "bug-fixer", mode: "subagent" }]
|
||||
const mockCtx = createMockCtx(agents)
|
||||
|
||||
@@ -140,6 +140,10 @@ export function createCallOmoAgent(
|
||||
`[call_omo_agent] Starting with agent: ${args.subagent_type}, background: ${args.run_in_background}`,
|
||||
);
|
||||
|
||||
if (typeof args.subagent_type !== "string" || args.subagent_type.trim() === "") {
|
||||
return "Error: subagent_type is required."
|
||||
}
|
||||
|
||||
const callableAgents = await resolveCallableAgents(ctx.client);
|
||||
|
||||
// Strip ZWSP and case-insensitive agent validation - allows "Explore", "EXPLORE", "explore" etc.
|
||||
|
||||
@@ -272,7 +272,7 @@ describe("executeSyncTask - cleanup on error paths", () => {
|
||||
}
|
||||
const fallbackChain = [
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-7", variant: "max" },
|
||||
{ providers: ["opencode-go"], model: "kimi-k2.5" },
|
||||
{ providers: ["opencode-go"], model: "kimi-k2.6" },
|
||||
]
|
||||
|
||||
//#when
|
||||
@@ -282,10 +282,10 @@ describe("executeSyncTask - cleanup on error paths", () => {
|
||||
|
||||
//#then
|
||||
expect(result).toContain("Task completed")
|
||||
expect(result).toContain("Model: opencode-go/kimi-k2.5")
|
||||
expect(result).toContain("Model: opencode-go/kimi-k2.6")
|
||||
expect(attemptedModels).toEqual([
|
||||
{ providerID: "anthropic", modelID: "claude-opus-4-7", variant: "max" },
|
||||
{ providerID: "opencode-go", modelID: "kimi-k2.5", variant: undefined },
|
||||
{ providerID: "opencode-go", modelID: "kimi-k2.6", variant: undefined },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -339,7 +339,7 @@ describe("executeSyncTask - cleanup on error paths", () => {
|
||||
}
|
||||
const fallbackChain = [
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-7", variant: "max" },
|
||||
{ providers: ["opencode-go"], model: "kimi-k2.5" },
|
||||
{ providers: ["opencode-go"], model: "kimi-k2.6" },
|
||||
{ providers: ["openai"], model: "gpt-5.4", variant: "medium" },
|
||||
]
|
||||
|
||||
@@ -352,7 +352,7 @@ describe("executeSyncTask - cleanup on error paths", () => {
|
||||
expect(result).toBe("Final failure")
|
||||
expect(attemptedModels).toEqual([
|
||||
{ providerID: "anthropic", modelID: "claude-opus-4-7", variant: "max" },
|
||||
{ providerID: "opencode-go", modelID: "kimi-k2.5", variant: undefined },
|
||||
{ providerID: "opencode-go", modelID: "kimi-k2.6", variant: undefined },
|
||||
{ providerID: "openai", modelID: "gpt-5.4", variant: "medium" },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -5,29 +5,29 @@ describe("buildMultimodalLookerFallbackChain", () => {
|
||||
// given
|
||||
const { buildMultimodalLookerFallbackChain } = await import("./multimodal-fallback-chain")
|
||||
const visionCapableModels = [
|
||||
{ providerID: "openai", modelID: "gpt-5.4" },
|
||||
{ providerID: "opencode", modelID: "gpt-5.4" },
|
||||
{ providerID: "openai", modelID: "gpt-5.5" },
|
||||
{ providerID: "opencode", modelID: "gpt-5.5" },
|
||||
]
|
||||
|
||||
// when
|
||||
const result = buildMultimodalLookerFallbackChain(visionCapableModels)
|
||||
|
||||
// then
|
||||
const gpt54Entries = result.filter((entry) => entry.model === "gpt-5.4")
|
||||
expect(gpt54Entries.length).toBeGreaterThan(0)
|
||||
const gpt55Entries = result.filter((entry) => entry.model === "gpt-5.5")
|
||||
expect(gpt55Entries.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("avoids duplicates when adding hardcoded entries", async () => {
|
||||
// given
|
||||
const { buildMultimodalLookerFallbackChain } = await import("./multimodal-fallback-chain")
|
||||
const visionCapableModels = [{ providerID: "openai", modelID: "gpt-5.4" }]
|
||||
const visionCapableModels = [{ providerID: "openai", modelID: "gpt-5.5" }]
|
||||
|
||||
// when
|
||||
const result = buildMultimodalLookerFallbackChain(visionCapableModels)
|
||||
|
||||
// then
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
expect(result[0].model).toBe("gpt-5.4")
|
||||
expect(result[0].model).toBe("gpt-5.5")
|
||||
expect(result[0].providers).toContain("openai")
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user