Compare commits
84 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68c8f3dda7 | ||
|
|
03f6e72c9b | ||
|
|
4fd9f0fd04 | ||
|
|
4413336724 | ||
|
|
895f366a11 | ||
|
|
acc19fcd41 | ||
|
|
68e0a32183 | ||
|
|
dee89c1556 | ||
|
|
315c75c51e | ||
|
|
3dd80889a5 | ||
|
|
8f6ed5b20f | ||
|
|
01500f1ebe | ||
|
|
48f6c5e06d | ||
|
|
3e32afe646 | ||
|
|
d11c4a1f81 | ||
|
|
5558ddf468 | ||
|
|
aa03d9b811 | ||
|
|
28a0dd06c7 | ||
|
|
995b7751af | ||
|
|
5087788f66 | ||
|
|
19524c8a27 | ||
|
|
fbb4d46945 | ||
|
|
5dc8d577a4 | ||
|
|
c249763d7e | ||
|
|
b2d618e851 | ||
|
|
6f348a8a5c | ||
|
|
1da0adcbe8 | ||
|
|
8a9d966a3d | ||
|
|
76f8c500cb | ||
|
|
388516bcc5 | ||
|
|
8dff875929 | ||
|
|
966cc90a02 | ||
|
|
1d27d78127 | ||
|
|
38156d49f3 | ||
|
|
897eea0263 | ||
|
|
9b59ef66e4 | ||
|
|
0d938059f9 | ||
|
|
9d35f23725 | ||
|
|
aa1646f82c | ||
|
|
e47ab084fd | ||
|
|
baf6358736 | ||
|
|
488c89156b | ||
|
|
c4957a469d | ||
|
|
d481c596bd | ||
|
|
655d511294 | ||
|
|
7dedd6cf90 | ||
|
|
bd18f231f5 | ||
|
|
de439edc22 | ||
|
|
04500bae7d | ||
|
|
1cb6b3de7d | ||
|
|
912a56db85 | ||
|
|
a5d9929c0a | ||
|
|
7f43f160b5 | ||
|
|
af67bc8592 | ||
|
|
c74d79e28a | ||
|
|
fc5298d778 | ||
|
|
3e8e3db961 | ||
|
|
6fa5cac616 | ||
|
|
158ccabf24 | ||
|
|
2efbf2650f | ||
|
|
acded4ba2a | ||
|
|
911e43445f | ||
|
|
3049e1ebfb | ||
|
|
62921b9e44 | ||
|
|
cd23f7ab7d | ||
|
|
518dceac72 | ||
|
|
19f43e30c8 | ||
|
|
b3be9f33c6 | ||
|
|
430098856a | ||
|
|
5932f5f94f | ||
|
|
fcf2e32071 | ||
|
|
19827dac70 | ||
|
|
3ed1c6644e | ||
|
|
cf6e714946 | ||
|
|
383f43548b | ||
|
|
26b1c67964 | ||
|
|
7e065dfe12 | ||
|
|
8429da02b8 | ||
|
|
ab51f5d39f | ||
|
|
3ee519c7b0 | ||
|
|
c9b86b7815 | ||
|
|
9b6d8f629a | ||
|
|
6a2f43858a | ||
|
|
601ea32a1c |
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -14,6 +14,8 @@ body:
|
||||
label: Prerequisites
|
||||
description: Please confirm the following before submitting
|
||||
options:
|
||||
- label: I will write this issue in English (see our [Language Policy](https://github.com/code-yeongyu/oh-my-opencode/blob/dev/CONTRIBUTING.md#language-policy))
|
||||
required: true
|
||||
- label: I have searched existing issues to avoid duplicates
|
||||
required: true
|
||||
- label: I am using the latest version of oh-my-opencode
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -14,6 +14,8 @@ body:
|
||||
label: Prerequisites
|
||||
description: Please confirm the following before submitting
|
||||
options:
|
||||
- label: I will write this issue in English (see our [Language Policy](https://github.com/code-yeongyu/oh-my-opencode/blob/dev/CONTRIBUTING.md#language-policy))
|
||||
required: true
|
||||
- label: I have searched existing issues and discussions to avoid duplicates
|
||||
required: true
|
||||
- label: This feature request is specific to oh-my-opencode (not OpenCode core)
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/general.yml
vendored
2
.github/ISSUE_TEMPLATE/general.yml
vendored
@@ -14,6 +14,8 @@ body:
|
||||
label: Prerequisites
|
||||
description: Please confirm the following before submitting
|
||||
options:
|
||||
- label: I will write this issue in English (see our [Language Policy](https://github.com/code-yeongyu/oh-my-opencode/blob/dev/CONTRIBUTING.md#language-policy))
|
||||
required: true
|
||||
- label: I have searched existing issues and discussions
|
||||
required: true
|
||||
- label: I have read the [documentation](https://github.com/code-yeongyu/oh-my-opencode#readme)
|
||||
|
||||
30
.github/workflows/ci.yml
vendored
30
.github/workflows/ci.yml
vendored
@@ -44,8 +44,34 @@ jobs:
|
||||
env:
|
||||
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
|
||||
|
||||
- name: Run tests
|
||||
run: bun test
|
||||
- name: Run mock-heavy tests (isolated)
|
||||
run: |
|
||||
# These files use mock.module() which pollutes module cache
|
||||
# Run them in separate processes to prevent cross-file contamination
|
||||
bun test src/plugin-handlers
|
||||
bun test src/hooks/atlas
|
||||
bun test src/hooks/compaction-context-injector
|
||||
bun test src/features/tmux-subagent
|
||||
|
||||
- name: Run remaining tests
|
||||
run: |
|
||||
# Run all other tests (mock-heavy ones are re-run but that's acceptable)
|
||||
bun test bin script src/cli src/config src/mcp src/index.test.ts \
|
||||
src/agents src/tools src/shared \
|
||||
src/hooks/anthropic-context-window-limit-recovery \
|
||||
src/hooks/claude-code-compatibility \
|
||||
src/hooks/context-injection \
|
||||
src/hooks/provider-toast \
|
||||
src/hooks/session-notification \
|
||||
src/hooks/sisyphus \
|
||||
src/hooks/todo-continuation-enforcer \
|
||||
src/features/background-agent \
|
||||
src/features/builtin-commands \
|
||||
src/features/builtin-skills \
|
||||
src/features/claude-code-session-state \
|
||||
src/features/hook-message-injector \
|
||||
src/features/opencode-skill-loader \
|
||||
src/features/skill-mcp-manager
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
2
.github/workflows/cla.yml
vendored
2
.github/workflows/cla.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
path-to-signatures: 'signatures/cla.json'
|
||||
path-to-document: 'https://github.com/code-yeongyu/oh-my-opencode/blob/master/CLA.md'
|
||||
branch: 'dev'
|
||||
allowlist: bot*,dependabot*,github-actions*,*[bot],sisyphus-dev-ai
|
||||
allowlist: code-yeongyu,bot*,dependabot*,github-actions*,*[bot],sisyphus-dev-ai
|
||||
custom-notsigned-prcomment: |
|
||||
Thank you for your contribution! Before we can merge this PR, we need you to sign our [Contributor License Agreement (CLA)](https://github.com/code-yeongyu/oh-my-opencode/blob/master/CLA.md).
|
||||
|
||||
|
||||
30
.github/workflows/publish.yml
vendored
30
.github/workflows/publish.yml
vendored
@@ -45,8 +45,34 @@ jobs:
|
||||
env:
|
||||
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
|
||||
|
||||
- name: Run tests
|
||||
run: bun test
|
||||
- name: Run mock-heavy tests (isolated)
|
||||
run: |
|
||||
# These files use mock.module() which pollutes module cache
|
||||
# Run them in separate processes to prevent cross-file contamination
|
||||
bun test src/plugin-handlers
|
||||
bun test src/hooks/atlas
|
||||
bun test src/hooks/compaction-context-injector
|
||||
bun test src/features/tmux-subagent
|
||||
|
||||
- name: Run remaining tests
|
||||
run: |
|
||||
# Run all other tests (mock-heavy ones are re-run but that's acceptable)
|
||||
bun test bin script src/cli src/config src/mcp src/index.test.ts \
|
||||
src/agents src/tools src/shared \
|
||||
src/hooks/anthropic-context-window-limit-recovery \
|
||||
src/hooks/claude-code-compatibility \
|
||||
src/hooks/context-injection \
|
||||
src/hooks/provider-toast \
|
||||
src/hooks/session-notification \
|
||||
src/hooks/sisyphus \
|
||||
src/hooks/todo-continuation-enforcer \
|
||||
src/features/background-agent \
|
||||
src/features/builtin-commands \
|
||||
src/features/builtin-skills \
|
||||
src/features/claude-code-session-state \
|
||||
src/features/hook-message-injector \
|
||||
src/features/opencode-skill-loader \
|
||||
src/features/skill-mcp-manager
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
38
.github/workflows/sisyphus-agent.yml
vendored
38
.github/workflows/sisyphus-agent.yml
vendored
@@ -152,6 +152,41 @@ jobs:
|
||||
"limit": { "context": 200000, "output": 64000 }
|
||||
}
|
||||
}
|
||||
} |
|
||||
.provider["zai-coding-plan"] = {
|
||||
"name": "Z.AI Coding Plan",
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"options": {
|
||||
"baseURL": "https://api.z.ai/api/paas/v4"
|
||||
},
|
||||
"models": {
|
||||
"glm-4.7": {
|
||||
"id": "glm-4.7",
|
||||
"name": "GLM 4.7",
|
||||
"limit": { "context": 128000, "output": 16000 }
|
||||
},
|
||||
"glm-4.6v": {
|
||||
"id": "glm-4.6v",
|
||||
"name": "GLM 4.6 Vision",
|
||||
"limit": { "context": 128000, "output": 16000 }
|
||||
}
|
||||
}
|
||||
} |
|
||||
.provider.openai = {
|
||||
"name": "OpenAI",
|
||||
"npm": "@ai-sdk/openai",
|
||||
"models": {
|
||||
"gpt-5.2": {
|
||||
"id": "gpt-5.2",
|
||||
"name": "GPT-5.2",
|
||||
"limit": { "context": 128000, "output": 16000 }
|
||||
},
|
||||
"gpt-5.2-codex": {
|
||||
"id": "gpt-5.2-codex",
|
||||
"name": "GPT-5.2 Codex",
|
||||
"limit": { "context": 128000, "output": 32000 }
|
||||
}
|
||||
}
|
||||
}
|
||||
' "$OPENCODE_JSON" > /tmp/oc.json && mv /tmp/oc.json "$OPENCODE_JSON"
|
||||
|
||||
@@ -287,6 +322,9 @@ jobs:
|
||||
)
|
||||
jq --arg append "$PROMPT_APPEND" '.agents.Sisyphus.prompt_append = $append' "$OMO_JSON" > /tmp/omo.json && mv /tmp/omo.json "$OMO_JSON"
|
||||
|
||||
# Add categories configuration for unspecified-low to use GLM 4.7
|
||||
jq '.categories["unspecified-low"] = { "model": "zai-coding-plan/glm-4.7" }' "$OMO_JSON" > /tmp/omo.json && mv /tmp/omo.json "$OMO_JSON"
|
||||
|
||||
mkdir -p ~/.local/share/opencode
|
||||
echo "$OPENCODE_AUTH_JSON" > ~/.local/share/opencode/auth.json
|
||||
chmod 600 ~/.local/share/opencode/auth.json
|
||||
|
||||
@@ -220,6 +220,51 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"maxTokens": {
|
||||
"type": "number"
|
||||
},
|
||||
"thinking": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"enabled",
|
||||
"disabled"
|
||||
]
|
||||
},
|
||||
"budgetTokens": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
]
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
]
|
||||
},
|
||||
"textVerbosity": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high"
|
||||
]
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -346,6 +391,51 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"maxTokens": {
|
||||
"type": "number"
|
||||
},
|
||||
"thinking": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"enabled",
|
||||
"disabled"
|
||||
]
|
||||
},
|
||||
"budgetTokens": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
]
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
]
|
||||
},
|
||||
"textVerbosity": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high"
|
||||
]
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -472,6 +562,51 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"maxTokens": {
|
||||
"type": "number"
|
||||
},
|
||||
"thinking": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"enabled",
|
||||
"disabled"
|
||||
]
|
||||
},
|
||||
"budgetTokens": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
]
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
]
|
||||
},
|
||||
"textVerbosity": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high"
|
||||
]
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -598,6 +733,51 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"maxTokens": {
|
||||
"type": "number"
|
||||
},
|
||||
"thinking": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"enabled",
|
||||
"disabled"
|
||||
]
|
||||
},
|
||||
"budgetTokens": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
]
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
]
|
||||
},
|
||||
"textVerbosity": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high"
|
||||
]
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -724,6 +904,51 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"maxTokens": {
|
||||
"type": "number"
|
||||
},
|
||||
"thinking": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"enabled",
|
||||
"disabled"
|
||||
]
|
||||
},
|
||||
"budgetTokens": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
]
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
]
|
||||
},
|
||||
"textVerbosity": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high"
|
||||
]
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -850,6 +1075,51 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"maxTokens": {
|
||||
"type": "number"
|
||||
},
|
||||
"thinking": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"enabled",
|
||||
"disabled"
|
||||
]
|
||||
},
|
||||
"budgetTokens": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
]
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
]
|
||||
},
|
||||
"textVerbosity": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high"
|
||||
]
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -976,6 +1246,51 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"maxTokens": {
|
||||
"type": "number"
|
||||
},
|
||||
"thinking": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"enabled",
|
||||
"disabled"
|
||||
]
|
||||
},
|
||||
"budgetTokens": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
]
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
]
|
||||
},
|
||||
"textVerbosity": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high"
|
||||
]
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1102,6 +1417,51 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"maxTokens": {
|
||||
"type": "number"
|
||||
},
|
||||
"thinking": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"enabled",
|
||||
"disabled"
|
||||
]
|
||||
},
|
||||
"budgetTokens": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
]
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
]
|
||||
},
|
||||
"textVerbosity": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high"
|
||||
]
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1228,6 +1588,51 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"maxTokens": {
|
||||
"type": "number"
|
||||
},
|
||||
"thinking": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"enabled",
|
||||
"disabled"
|
||||
]
|
||||
},
|
||||
"budgetTokens": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
]
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
]
|
||||
},
|
||||
"textVerbosity": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high"
|
||||
]
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1354,6 +1759,51 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"maxTokens": {
|
||||
"type": "number"
|
||||
},
|
||||
"thinking": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"enabled",
|
||||
"disabled"
|
||||
]
|
||||
},
|
||||
"budgetTokens": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
]
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
]
|
||||
},
|
||||
"textVerbosity": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high"
|
||||
]
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1480,6 +1930,51 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"maxTokens": {
|
||||
"type": "number"
|
||||
},
|
||||
"thinking": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"enabled",
|
||||
"disabled"
|
||||
]
|
||||
},
|
||||
"budgetTokens": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
]
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
]
|
||||
},
|
||||
"textVerbosity": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high"
|
||||
]
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1606,6 +2101,51 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"maxTokens": {
|
||||
"type": "number"
|
||||
},
|
||||
"thinking": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"enabled",
|
||||
"disabled"
|
||||
]
|
||||
},
|
||||
"budgetTokens": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
]
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
]
|
||||
},
|
||||
"textVerbosity": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high"
|
||||
]
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1732,6 +2272,51 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"maxTokens": {
|
||||
"type": "number"
|
||||
},
|
||||
"thinking": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"enabled",
|
||||
"disabled"
|
||||
]
|
||||
},
|
||||
"budgetTokens": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
]
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
]
|
||||
},
|
||||
"textVerbosity": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high"
|
||||
]
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2183,7 +2768,8 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"playwright",
|
||||
"agent-browser"
|
||||
"agent-browser",
|
||||
"dev-browser"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -2223,6 +2809,50 @@
|
||||
"minimum": 20
|
||||
}
|
||||
}
|
||||
},
|
||||
"sisyphus": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tasks": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"storage_path": {
|
||||
"default": ".sisyphus/tasks",
|
||||
"type": "string"
|
||||
},
|
||||
"claude_code_compat": {
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"swarm": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"storage_path": {
|
||||
"default": ".sisyphus/teams",
|
||||
"type": "string"
|
||||
},
|
||||
"ui_mode": {
|
||||
"default": "toast",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"toast",
|
||||
"tmux",
|
||||
"both"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
28
bun.lock
28
bun.lock
@@ -27,13 +27,13 @@
|
||||
"typescript": "^5.7.3",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"oh-my-opencode-darwin-arm64": "3.0.1",
|
||||
"oh-my-opencode-darwin-x64": "3.0.1",
|
||||
"oh-my-opencode-linux-arm64": "3.0.1",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.0.1",
|
||||
"oh-my-opencode-linux-x64": "3.0.1",
|
||||
"oh-my-opencode-linux-x64-musl": "3.0.1",
|
||||
"oh-my-opencode-windows-x64": "3.0.1",
|
||||
"oh-my-opencode-darwin-arm64": "3.1.2",
|
||||
"oh-my-opencode-darwin-x64": "3.1.2",
|
||||
"oh-my-opencode-linux-arm64": "3.1.2",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.1.2",
|
||||
"oh-my-opencode-linux-x64": "3.1.2",
|
||||
"oh-my-opencode-linux-x64-musl": "3.1.2",
|
||||
"oh-my-opencode-windows-x64": "3.1.2",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -225,20 +225,6 @@
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.0.1", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-LRcLVi6DsmGh3ICFeN4yVJ0KinvCM5jotd2z7tZQ74n0sziHO7grjK1CmJaPV9eCv0clatoK5xfFCeEJ3FvXYg=="],
|
||||
|
||||
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.0.1", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-ZaC0ZBe5M2f2aMncNsAMu9IZ3MjSPfNVcfUTCgJkp03db8lLPsajgjeG3556Er72hxignDPsEbrLkJBNlsDbAA=="],
|
||||
|
||||
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.0.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-pcOvV6Y2GSwKr0exDndeB2BtFt297XhJFQgrq1cbeEJawoRONDRp7LNSpjwILSQpQ7YkkYnO2bIczBmxI5llNA=="],
|
||||
|
||||
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.0.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-7kXKaVbgFnOMSaw+j4JbZNs7O7mkvCekcfWPwh/9I/0WD21/n4PbAGl01ePhRoQh+u9MC6t8FH046hEjL2sk1g=="],
|
||||
|
||||
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.0.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-1BOV1EnKa5BErhZmWiddnbriHwm1KFrPr+0BUCDdFX/d/hrMAJTo1733zaEnvKuXzvrdHSp/VznXheeUI1VjkA=="],
|
||||
|
||||
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.0.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-ASyTVatvU1nNJ0mk9o+A/GjybT5vOdgU172ystzCsnQ+12Mnv68GgaeMu/UFJgJNaZmKdhyUAP9XhnOKvEDBGQ=="],
|
||||
|
||||
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.0.1", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-QIuA564mVpwzCprhhAoyd8TSw0Rt2VM6M9y7H0fOoC/UjXuU+d7wIuUNuqUUMVaUnMedkctTZop0X0i2Q+Bvhg=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
@@ -85,6 +85,66 @@ When both `oh-my-opencode.jsonc` and `oh-my-opencode.json` files exist, `.jsonc`
|
||||
|
||||
**Recommended**: For Google Gemini authentication, install the [`opencode-antigravity-auth`](https://github.com/NoeFabris/opencode-antigravity-auth) plugin (`@latest`). It provides multi-account load balancing, variant-based thinking levels, dual quota system (Antigravity + Gemini CLI), and active maintenance. See [Installation > Google Gemini](docs/guide/installation.md#google-gemini-antigravity-oauth).
|
||||
|
||||
## Ollama Provider
|
||||
|
||||
**IMPORTANT**: When using Ollama as a provider, you **must** disable streaming to avoid JSON parsing errors.
|
||||
|
||||
### Required Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"explore": {
|
||||
"model": "ollama/qwen3-coder",
|
||||
"stream": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Why `stream: false` is Required
|
||||
|
||||
Ollama returns NDJSON (newline-delimited JSON) when streaming is enabled, but Claude Code SDK expects a single JSON object. This causes `JSON Parse error: Unexpected EOF` when agents attempt tool calls.
|
||||
|
||||
**Example of the problem**:
|
||||
```json
|
||||
// Ollama streaming response (NDJSON - multiple lines)
|
||||
{"message":{"tool_calls":[...]}, "done":false}
|
||||
{"message":{"content":""}, "done":true}
|
||||
|
||||
// Claude Code SDK expects (single JSON object)
|
||||
{"message":{"tool_calls":[...], "content":""}, "done":true}
|
||||
```
|
||||
|
||||
### Supported Models
|
||||
|
||||
Common Ollama models that work with oh-my-opencode:
|
||||
|
||||
| Model | Best For | Configuration |
|
||||
|-------|----------|---------------|
|
||||
| `ollama/qwen3-coder` | Code generation, build fixes | `{"model": "ollama/qwen3-coder", "stream": false}` |
|
||||
| `ollama/ministral-3:14b` | Exploration, codebase search | `{"model": "ollama/ministral-3:14b", "stream": false}` |
|
||||
| `ollama/lfm2.5-thinking` | Documentation, writing | `{"model": "ollama/lfm2.5-thinking", "stream": false}` |
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
If you encounter `JSON Parse error: Unexpected EOF`:
|
||||
|
||||
1. **Verify `stream: false` is set** in your agent configuration
|
||||
2. **Check Ollama is running**: `curl http://localhost:11434/api/tags`
|
||||
3. **Test with curl**:
|
||||
```bash
|
||||
curl -s http://localhost:11434/api/chat \
|
||||
-d '{"model": "qwen3-coder", "messages": [{"role": "user", "content": "Hello"}], "stream": false}'
|
||||
```
|
||||
4. **See detailed troubleshooting**: [docs/troubleshooting/ollama-streaming-issue.md](troubleshooting/ollama-streaming-issue.md)
|
||||
|
||||
### Future SDK Fix
|
||||
|
||||
The proper long-term fix requires Claude Code SDK to parse NDJSON responses correctly. Until then, use `stream: false` as a workaround.
|
||||
|
||||
**Tracking**: https://github.com/code-yeongyu/oh-my-opencode/issues/1124
|
||||
|
||||
## Agents
|
||||
|
||||
Override built-in agent settings:
|
||||
@@ -219,6 +279,183 @@ agent-browser screenshot result.png
|
||||
agent-browser close
|
||||
```
|
||||
|
||||
## Tmux Integration
|
||||
|
||||
Run background subagents in separate tmux panes for **visual multi-agent execution**. See your agents working in parallel, each in their own terminal pane.
|
||||
|
||||
**Enable tmux integration** via `tmux` in `oh-my-opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"tmux": {
|
||||
"enabled": true,
|
||||
"layout": "main-vertical",
|
||||
"main_pane_size": 60,
|
||||
"main_pane_min_width": 120,
|
||||
"agent_pane_min_width": 40
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `enabled` | `false` | Enable tmux subagent pane spawning. Only works when running inside an existing tmux session. |
|
||||
| `layout` | `main-vertical` | Tmux layout for agent panes. See [Layout Options](#layout-options) below. |
|
||||
| `main_pane_size` | `60` | Main pane size as percentage (20-80). |
|
||||
| `main_pane_min_width` | `120` | Minimum width for main pane in columns. |
|
||||
| `agent_pane_min_width` | `40` | Minimum width for each agent pane in columns. |
|
||||
|
||||
### Layout Options
|
||||
|
||||
| Layout | Description |
|
||||
|--------|-------------|
|
||||
| `main-vertical` | Main pane left, agent panes stacked on right (default) |
|
||||
| `main-horizontal` | Main pane top, agent panes stacked bottom |
|
||||
| `tiled` | All panes in equal-sized grid |
|
||||
| `even-horizontal` | All panes in horizontal row |
|
||||
| `even-vertical` | All panes in vertical stack |
|
||||
|
||||
### Requirements
|
||||
|
||||
1. **Must run inside tmux**: The feature only activates when OpenCode is already running inside a tmux session
|
||||
2. **Tmux installed**: Requires tmux to be available in PATH
|
||||
3. **Server mode**: OpenCode must run with `--port` flag to enable subagent pane spawning
|
||||
|
||||
### How It Works
|
||||
|
||||
When `tmux.enabled` is `true` and you're inside a tmux session:
|
||||
- Background agents (via `delegate_task(run_in_background=true)`) spawn in new tmux panes
|
||||
- Each pane shows the subagent's real-time output
|
||||
- Panes are automatically closed when the subagent completes
|
||||
- Layout is automatically adjusted based on your configuration
|
||||
|
||||
### Running OpenCode with Tmux Subagent Support
|
||||
|
||||
To enable tmux subagent panes, OpenCode must run in **server mode** with the `--port` flag. This starts an HTTP server that subagent panes connect to via `opencode attach`.
|
||||
|
||||
**Basic setup**:
|
||||
```bash
|
||||
# Start tmux session
|
||||
tmux new -s dev
|
||||
|
||||
# Run OpenCode with server mode (port 4096)
|
||||
opencode --port 4096
|
||||
|
||||
# Now background agents will appear in separate panes
|
||||
```
|
||||
|
||||
**Recommended: Shell Function**
|
||||
|
||||
For convenience, create a shell function that automatically handles tmux sessions and port allocation. Here's an example for Fish shell:
|
||||
|
||||
```fish
|
||||
# ~/.config/fish/config.fish
|
||||
function oc
|
||||
set base_name (basename (pwd))
|
||||
set path_hash (echo (pwd) | md5 | cut -c1-4)
|
||||
set session_name "$base_name-$path_hash"
|
||||
|
||||
# Find available port starting from 4096
|
||||
function __oc_find_port
|
||||
set port 4096
|
||||
while test $port -lt 5096
|
||||
if not lsof -i :$port >/dev/null 2>&1
|
||||
echo $port
|
||||
return 0
|
||||
end
|
||||
set port (math $port + 1)
|
||||
end
|
||||
echo 4096
|
||||
end
|
||||
|
||||
set oc_port (__oc_find_port)
|
||||
set -x OPENCODE_PORT $oc_port
|
||||
|
||||
if set -q TMUX
|
||||
# Already inside tmux - just run with port
|
||||
opencode --port $oc_port $argv
|
||||
else
|
||||
# Create tmux session and run opencode
|
||||
set oc_cmd "OPENCODE_PORT=$oc_port opencode --port $oc_port $argv; exec fish"
|
||||
if tmux has-session -t "$session_name" 2>/dev/null
|
||||
tmux new-window -t "$session_name" -c (pwd) "$oc_cmd"
|
||||
tmux attach-session -t "$session_name"
|
||||
else
|
||||
tmux new-session -s "$session_name" -c (pwd) "$oc_cmd"
|
||||
end
|
||||
end
|
||||
|
||||
functions -e __oc_find_port
|
||||
end
|
||||
```
|
||||
|
||||
**Bash/Zsh equivalent**:
|
||||
|
||||
```bash
|
||||
# ~/.bashrc or ~/.zshrc
|
||||
oc() {
|
||||
local base_name=$(basename "$PWD")
|
||||
local path_hash=$(echo "$PWD" | md5sum | cut -c1-4)
|
||||
local session_name="${base_name}-${path_hash}"
|
||||
|
||||
# Find available port
|
||||
local port=4096
|
||||
while [ $port -lt 5096 ]; do
|
||||
if ! lsof -i :$port >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
port=$((port + 1))
|
||||
done
|
||||
|
||||
export OPENCODE_PORT=$port
|
||||
|
||||
if [ -n "$TMUX" ]; then
|
||||
opencode --port $port "$@"
|
||||
else
|
||||
local oc_cmd="OPENCODE_PORT=$port opencode --port $port $*; exec $SHELL"
|
||||
if tmux has-session -t "$session_name" 2>/dev/null; then
|
||||
tmux new-window -t "$session_name" -c "$PWD" "$oc_cmd"
|
||||
tmux attach-session -t "$session_name"
|
||||
else
|
||||
tmux new-session -s "$session_name" -c "$PWD" "$oc_cmd"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
```
|
||||
|
||||
**How subagent panes work**:
|
||||
|
||||
1. Main OpenCode starts HTTP server on specified port (e.g., `http://localhost:4096`)
|
||||
2. When a background agent spawns, Oh My OpenCode creates a new tmux pane
|
||||
3. The pane runs: `opencode attach http://localhost:4096 --session <session-id>`
|
||||
4. Each subagent pane shows real-time streaming output
|
||||
5. Panes are automatically closed when the subagent completes
|
||||
|
||||
**Environment variables**:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `OPENCODE_PORT` | Default port for the HTTP server (used if `--port` not specified) |
|
||||
|
||||
### Server Mode Reference
|
||||
|
||||
OpenCode's server mode exposes an HTTP API for programmatic interaction:
|
||||
|
||||
```bash
|
||||
# Standalone server (no TUI)
|
||||
opencode serve --port 4096
|
||||
|
||||
# TUI with server (recommended for tmux integration)
|
||||
opencode --port 4096
|
||||
```
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `--port` | `4096` | Port for HTTP server |
|
||||
| `--hostname` | `127.0.0.1` | Hostname to listen on |
|
||||
|
||||
For more details, see the [OpenCode Server documentation](https://opencode.ai/docs/server/).
|
||||
|
||||
## Git Master
|
||||
|
||||
Configure git-master skill behavior:
|
||||
@@ -348,27 +585,96 @@ Configure concurrency limits for background agent tasks. This controls how many
|
||||
|
||||
Categories enable domain-specific task delegation via the `delegate_task` tool. Each category applies runtime presets (model, temperature, prompt additions) when calling the `Sisyphus-Junior` agent.
|
||||
|
||||
**Default Categories:**
|
||||
### Built-in Categories
|
||||
|
||||
| Category | Model | Description |
|
||||
| ---------------- | ----------------------------- | ---------------------------------------------------------------------------- |
|
||||
| `visual` | `google/gemini-3-pro` | Frontend, UI/UX, design-focused tasks. High creativity (temp 0.7). |
|
||||
| `business-logic` | `openai/gpt-5.2` | Backend logic, architecture, strategic reasoning. Low creativity (temp 0.1). |
|
||||
All 7 categories come with optimal model defaults, but **you must configure them to use those defaults**:
|
||||
|
||||
**Usage:**
|
||||
| Category | Built-in Default Model | Description |
|
||||
| -------------------- | ---------------------------------- | -------------------------------------------------------------------- |
|
||||
| `visual-engineering` | `google/gemini-3-pro-preview` | Frontend, UI/UX, design, styling, animation |
|
||||
| `ultrabrain` | `openai/gpt-5.2-codex` (xhigh) | Deep logical reasoning, complex architecture decisions |
|
||||
| `artistry` | `google/gemini-3-pro-preview` (max)| Highly creative/artistic tasks, novel ideas |
|
||||
| `quick` | `anthropic/claude-haiku-4-5` | Trivial tasks - single file changes, typo fixes, simple modifications|
|
||||
| `unspecified-low` | `anthropic/claude-sonnet-4-5` | Tasks that don't fit other categories, low effort required |
|
||||
| `unspecified-high` | `anthropic/claude-opus-4-5` (max) | Tasks that don't fit other categories, high effort required |
|
||||
| `writing` | `google/gemini-3-flash-preview` | Documentation, prose, technical writing |
|
||||
|
||||
### ⚠️ Critical: Model Resolution Priority
|
||||
|
||||
**Categories DO NOT use their built-in defaults unless configured.** Model resolution follows this priority:
|
||||
|
||||
```
|
||||
// Via delegate_task tool
|
||||
delegate_task(category="visual", prompt="Create a responsive dashboard component")
|
||||
delegate_task(category="business-logic", prompt="Design the payment processing flow")
|
||||
1. User-configured model (in oh-my-opencode.json)
|
||||
2. Category's built-in default (if you add category to config)
|
||||
3. System default model (from opencode.json)
|
||||
```
|
||||
|
||||
// Or target a specific agent directly
|
||||
**Example Problem:**
|
||||
|
||||
```json
|
||||
// opencode.json
|
||||
{ "model": "anthropic/claude-sonnet-4-5" }
|
||||
|
||||
// oh-my-opencode.json (empty categories section)
|
||||
{}
|
||||
|
||||
// Result: ALL categories use claude-sonnet-4-5 (wasteful!)
|
||||
// - quick tasks use Sonnet instead of Haiku (expensive)
|
||||
// - ultrabrain uses Sonnet instead of GPT-5.2 (inferior reasoning)
|
||||
// - visual tasks use Sonnet instead of Gemini (suboptimal for UI)
|
||||
```
|
||||
|
||||
### Recommended Configuration
|
||||
|
||||
**To use optimal models for each category, add them to your config:**
|
||||
|
||||
```json
|
||||
{
|
||||
"categories": {
|
||||
"visual-engineering": {
|
||||
"model": "google/gemini-3-pro-preview"
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
"variant": "xhigh"
|
||||
},
|
||||
"artistry": {
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"variant": "max"
|
||||
},
|
||||
"quick": {
|
||||
"model": "anthropic/claude-haiku-4-5" // Fast + cheap for trivial tasks
|
||||
},
|
||||
"unspecified-low": {
|
||||
"model": "anthropic/claude-sonnet-4-5"
|
||||
},
|
||||
"unspecified-high": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"variant": "max"
|
||||
},
|
||||
"writing": {
|
||||
"model": "google/gemini-3-flash-preview"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Only configure categories you have access to.** Unconfigured categories fall back to your system default model.
|
||||
|
||||
### Usage
|
||||
|
||||
```javascript
|
||||
// Via delegate_task tool
|
||||
delegate_task(category="visual-engineering", prompt="Create a responsive dashboard component")
|
||||
delegate_task(category="ultrabrain", prompt="Design the payment processing flow")
|
||||
|
||||
// Or target a specific agent directly (bypasses categories)
|
||||
delegate_task(agent="oracle", prompt="Review this architecture")
|
||||
```
|
||||
|
||||
**Custom Categories:**
|
||||
### Custom Categories
|
||||
|
||||
Add custom categories in `oh-my-opencode.json`:
|
||||
Add your own categories or override built-in ones:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -378,15 +684,15 @@ Add custom categories in `oh-my-opencode.json`:
|
||||
"temperature": 0.2,
|
||||
"prompt_append": "Focus on data analysis, ML pipelines, and statistical methods."
|
||||
},
|
||||
"visual": {
|
||||
"model": "google/gemini-3-pro",
|
||||
"visual-engineering": {
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"prompt_append": "Use shadcn/ui components and Tailwind CSS."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each category supports: `model`, `temperature`, `top_p`, `maxTokens`, `thinking`, `reasoningEffort`, `textVerbosity`, `tools`, `prompt_append`.
|
||||
Each category supports: `model`, `temperature`, `top_p`, `maxTokens`, `thinking`, `reasoningEffort`, `textVerbosity`, `tools`, `prompt_append`, `variant`.
|
||||
|
||||
## Model Resolution System
|
||||
|
||||
@@ -522,6 +828,8 @@ Disable specific built-in hooks via `disabled_hooks` in `~/.config/opencode/oh-m
|
||||
|
||||
Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`, `preemptive-compaction`
|
||||
|
||||
**Note on `directory-agents-injector`**: This hook is **automatically disabled** when running on OpenCode 1.1.37+ because OpenCode now has native support for dynamically resolving AGENTS.md files from subdirectories (PR #10678). This prevents duplicate AGENTS.md injection. For older OpenCode versions, the hook remains active to provide the same functionality.
|
||||
|
||||
**Note on `auto-update-checker` and `startup-toast`**: The `startup-toast` hook is a sub-feature of `auto-update-checker`. To disable only the startup toast notification while keeping update checking enabled, add `"startup-toast"` to `disabled_hooks`. To disable all update checking features (including the toast), add `"auto-update-checker"` to `disabled_hooks`.
|
||||
|
||||
## MCPs
|
||||
|
||||
@@ -62,6 +62,27 @@ delegate_task(agent="explore", background=true, prompt="Find auth implementation
|
||||
background_output(task_id="bg_abc123")
|
||||
```
|
||||
|
||||
#### Visual Multi-Agent with Tmux
|
||||
|
||||
Enable `tmux.enabled` to see background agents in separate tmux panes:
|
||||
|
||||
```json
|
||||
{
|
||||
"tmux": {
|
||||
"enabled": true,
|
||||
"layout": "main-vertical"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When running inside tmux:
|
||||
- Background agents spawn in new panes
|
||||
- Watch multiple agents work in real-time
|
||||
- Each pane shows agent output live
|
||||
- Auto-cleanup when agents complete
|
||||
|
||||
See [Tmux Integration](configurations.md#tmux-integration) for full configuration options.
|
||||
|
||||
Customize agent models, prompts, and permissions in `oh-my-opencode.json`. See [Configuration](configurations.md#agents).
|
||||
|
||||
---
|
||||
@@ -299,7 +320,7 @@ Hooks intercept and modify behavior at key points in the agent lifecycle.
|
||||
|
||||
| Hook | Event | Description |
|
||||
|------|-------|-------------|
|
||||
| **directory-agents-injector** | PostToolUse | Auto-injects AGENTS.md when reading files. Walks from file to project root, collecting all AGENTS.md files. |
|
||||
| **directory-agents-injector** | PostToolUse | Auto-injects AGENTS.md when reading files. Walks from file to project root, collecting all AGENTS.md files. **Deprecated for OpenCode 1.1.37+** - Auto-disabled when native AGENTS.md injection is available. |
|
||||
| **directory-readme-injector** | PostToolUse | Auto-injects README.md for directory context. |
|
||||
| **rules-injector** | PostToolUse | Injects rules from `.claude/rules/` when conditions match. Supports globs and alwaysApply. |
|
||||
| **compaction-context-injector** | Stop | Preserves critical context during session compaction. |
|
||||
@@ -445,6 +466,29 @@ Disable specific hooks in config:
|
||||
| **session_search** | Full-text search across session messages |
|
||||
| **session_info** | Get session metadata and statistics |
|
||||
|
||||
### Interactive Terminal Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| **interactive_bash** | Tmux-based terminal for TUI apps (vim, htop, pudb). Pass tmux subcommands directly without prefix. |
|
||||
|
||||
**Usage Examples**:
|
||||
```bash
|
||||
# Create a new session
|
||||
interactive_bash(tmux_command="new-session -d -s dev-app")
|
||||
|
||||
# Send keystrokes to a session
|
||||
interactive_bash(tmux_command="send-keys -t dev-app 'vim main.py' Enter")
|
||||
|
||||
# Capture pane output
|
||||
interactive_bash(tmux_command="capture-pane -p -t dev-app")
|
||||
```
|
||||
|
||||
**Key Points**:
|
||||
- Commands are tmux subcommands (no `tmux` prefix)
|
||||
- Use for interactive apps that need persistent sessions
|
||||
- One-shot commands should use regular `Bash` tool with `&`
|
||||
|
||||
---
|
||||
|
||||
## MCPs: Built-in Servers
|
||||
|
||||
126
docs/troubleshooting/ollama-streaming-issue.md
Normal file
126
docs/troubleshooting/ollama-streaming-issue.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# Ollama Streaming Issue - JSON Parse Error
|
||||
|
||||
## Problem
|
||||
|
||||
When using Ollama as a provider with oh-my-opencode agents, you may encounter:
|
||||
|
||||
```
|
||||
JSON Parse error: Unexpected EOF
|
||||
```
|
||||
|
||||
This occurs when agents attempt tool calls (e.g., `explore` agent using `mcp_grep_search`).
|
||||
|
||||
## Root Cause
|
||||
|
||||
Ollama returns **NDJSON** (newline-delimited JSON) when `stream: true` is used in API requests:
|
||||
|
||||
```json
|
||||
{"message":{"tool_calls":[{"function":{"name":"read","arguments":{"filePath":"README.md"}}}]}, "done":false}
|
||||
{"message":{"content":""}, "done":true}
|
||||
```
|
||||
|
||||
Claude Code SDK expects a single JSON object, not multiple NDJSON lines, causing the parse error.
|
||||
|
||||
### Why This Happens
|
||||
|
||||
- **Ollama API**: Returns streaming responses as NDJSON by design
|
||||
- **Claude Code SDK**: Doesn't properly handle NDJSON responses for tool calls
|
||||
- **oh-my-opencode**: Passes through the SDK's behavior (can't fix at this layer)
|
||||
|
||||
## Solutions
|
||||
|
||||
### Option 1: Disable Streaming (Recommended - Immediate Fix)
|
||||
|
||||
Configure your Ollama provider to use `stream: false`:
|
||||
|
||||
```json
|
||||
{
|
||||
"provider": "ollama",
|
||||
"model": "qwen3-coder",
|
||||
"stream": false
|
||||
}
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Works immediately
|
||||
- No code changes needed
|
||||
- Simple configuration
|
||||
|
||||
**Cons:**
|
||||
- Slightly slower response time (no streaming)
|
||||
- Less interactive feedback
|
||||
|
||||
### Option 2: Use Non-Tool Agents Only
|
||||
|
||||
If you need streaming, avoid agents that use tools:
|
||||
|
||||
- ✅ **Safe**: Simple text generation, non-tool tasks
|
||||
- ❌ **Problematic**: Any agent with tool calls (explore, librarian, etc.)
|
||||
|
||||
### Option 3: Wait for SDK Fix (Long-term)
|
||||
|
||||
The proper fix requires Claude Code SDK to:
|
||||
|
||||
1. Detect NDJSON responses
|
||||
2. Parse each line separately
|
||||
3. Merge `tool_calls` from multiple lines
|
||||
4. Return a single merged response
|
||||
|
||||
**Tracking**: https://github.com/code-yeongyu/oh-my-opencode/issues/1124
|
||||
|
||||
## Workaround Implementation
|
||||
|
||||
Until the SDK is fixed, here's how to implement NDJSON parsing (for SDK maintainers):
|
||||
|
||||
```typescript
|
||||
async function parseOllamaStreamResponse(response: string): Promise<object> {
|
||||
const lines = response.split('\n').filter(line => line.trim());
|
||||
const mergedMessage = { tool_calls: [] };
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const json = JSON.parse(line);
|
||||
if (json.message?.tool_calls) {
|
||||
mergedMessage.tool_calls.push(...json.message.tool_calls);
|
||||
}
|
||||
if (json.message?.content) {
|
||||
mergedMessage.content = json.message.content;
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip malformed lines
|
||||
console.warn('Skipping malformed NDJSON line:', line);
|
||||
}
|
||||
}
|
||||
|
||||
return mergedMessage;
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
To verify the fix works:
|
||||
|
||||
```bash
|
||||
# Test with curl (should work with stream: false)
|
||||
curl -s http://localhost:11434/api/chat \
|
||||
-d '{
|
||||
"model": "qwen3-coder",
|
||||
"messages": [{"role": "user", "content": "Read file README.md"}],
|
||||
"stream": false,
|
||||
"tools": [{"type": "function", "function": {"name": "read", "description": "Read a file", "parameters": {"type": "object", "properties": {"filePath": {"type": "string"}}, "required": ["filePath"]}}}]
|
||||
}'
|
||||
```
|
||||
|
||||
## Related Issues
|
||||
|
||||
- **oh-my-opencode**: https://github.com/code-yeongyu/oh-my-opencode/issues/1124
|
||||
- **Ollama API Docs**: https://github.com/ollama/ollama/blob/main/docs/api.md
|
||||
|
||||
## Getting Help
|
||||
|
||||
If you encounter this issue:
|
||||
|
||||
1. Check your Ollama provider configuration
|
||||
2. Set `stream: false` as a workaround
|
||||
3. Report any additional errors to the issue tracker
|
||||
4. Provide your configuration (without secrets) for debugging
|
||||
16
package.json
16
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "3.1.0",
|
||||
"version": "3.1.5",
|
||||
"description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -73,13 +73,13 @@
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"oh-my-opencode-darwin-arm64": "3.1.0",
|
||||
"oh-my-opencode-darwin-x64": "3.1.0",
|
||||
"oh-my-opencode-linux-arm64": "3.1.0",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.1.0",
|
||||
"oh-my-opencode-linux-x64": "3.1.0",
|
||||
"oh-my-opencode-linux-x64-musl": "3.1.0",
|
||||
"oh-my-opencode-windows-x64": "3.1.0"
|
||||
"oh-my-opencode-darwin-arm64": "3.1.5",
|
||||
"oh-my-opencode-darwin-x64": "3.1.5",
|
||||
"oh-my-opencode-linux-arm64": "3.1.5",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.1.5",
|
||||
"oh-my-opencode-linux-x64": "3.1.5",
|
||||
"oh-my-opencode-linux-x64-musl": "3.1.5",
|
||||
"oh-my-opencode-windows-x64": "3.1.5"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@ast-grep/cli",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-arm64",
|
||||
"version": "3.1.0",
|
||||
"version": "3.1.5",
|
||||
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
22
packages/darwin-x64-baseline/package.json
Normal file
22
packages/darwin-x64-baseline/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-x64-baseline",
|
||||
"version": "3.1.1",
|
||||
"description": "Platform-specific binary for oh-my-opencode (darwin-x64-baseline, no AVX2)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/code-yeongyu/oh-my-opencode"
|
||||
},
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"files": [
|
||||
"bin"
|
||||
],
|
||||
"bin": {
|
||||
"oh-my-opencode": "./bin/oh-my-opencode"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-x64",
|
||||
"version": "3.1.0",
|
||||
"version": "3.1.5",
|
||||
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-arm64-musl",
|
||||
"version": "3.1.0",
|
||||
"version": "3.1.5",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-arm64",
|
||||
"version": "3.1.0",
|
||||
"version": "3.1.5",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
25
packages/linux-x64-baseline/package.json
Normal file
25
packages/linux-x64-baseline/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64-baseline",
|
||||
"version": "3.1.1",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64-baseline, no AVX2)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/code-yeongyu/oh-my-opencode"
|
||||
},
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"files": [
|
||||
"bin"
|
||||
],
|
||||
"bin": {
|
||||
"oh-my-opencode": "./bin/oh-my-opencode"
|
||||
}
|
||||
}
|
||||
25
packages/linux-x64-musl-baseline/package.json
Normal file
25
packages/linux-x64-musl-baseline/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64-musl-baseline",
|
||||
"version": "3.1.1",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl-baseline, no AVX2)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/code-yeongyu/oh-my-opencode"
|
||||
},
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"files": [
|
||||
"bin"
|
||||
],
|
||||
"bin": {
|
||||
"oh-my-opencode": "./bin/oh-my-opencode"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64-musl",
|
||||
"version": "3.1.0",
|
||||
"version": "3.1.5",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64",
|
||||
"version": "3.1.0",
|
||||
"version": "3.1.5",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
22
packages/windows-x64-baseline/package.json
Normal file
22
packages/windows-x64-baseline/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "oh-my-opencode-windows-x64-baseline",
|
||||
"version": "3.1.1",
|
||||
"description": "Platform-specific binary for oh-my-opencode (windows-x64-baseline, no AVX2)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/code-yeongyu/oh-my-opencode"
|
||||
},
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"files": [
|
||||
"bin"
|
||||
],
|
||||
"bin": {
|
||||
"oh-my-opencode": "./bin/oh-my-opencode.exe"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-windows-x64",
|
||||
"version": "3.1.0",
|
||||
"version": "3.1.5",
|
||||
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
79
script/build-binaries.test.ts
Normal file
79
script/build-binaries.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
// script/build-binaries.test.ts
|
||||
// Tests for platform binary build configuration
|
||||
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
// Import PLATFORMS from build-binaries.ts
|
||||
// We need to export it first, but for now we'll test the expected structure
|
||||
const EXPECTED_BASELINE_TARGETS = [
|
||||
"bun-linux-x64-baseline",
|
||||
"bun-linux-x64-musl-baseline",
|
||||
"bun-darwin-x64-baseline",
|
||||
"bun-windows-x64-baseline",
|
||||
];
|
||||
|
||||
describe("build-binaries", () => {
|
||||
describe("PLATFORMS array", () => {
|
||||
it("includes baseline variants for non-AVX2 CPU support", async () => {
|
||||
// given
|
||||
const module = await import("./build-binaries.ts");
|
||||
const platforms = (module as { PLATFORMS: { target: string }[] }).PLATFORMS;
|
||||
const targets = platforms.map((p) => p.target);
|
||||
|
||||
// when
|
||||
const hasAllBaselineTargets = EXPECTED_BASELINE_TARGETS.every((baseline) =>
|
||||
targets.includes(baseline)
|
||||
);
|
||||
|
||||
// then
|
||||
expect(hasAllBaselineTargets).toBe(true);
|
||||
for (const baseline of EXPECTED_BASELINE_TARGETS) {
|
||||
expect(targets).toContain(baseline);
|
||||
}
|
||||
});
|
||||
|
||||
it("has correct directory names for baseline platforms", async () => {
|
||||
// given
|
||||
const module = await import("./build-binaries.ts");
|
||||
const platforms = (module as { PLATFORMS: { dir: string; target: string }[] }).PLATFORMS;
|
||||
|
||||
// when
|
||||
const baselinePlatforms = platforms.filter((p) => p.target.includes("baseline"));
|
||||
|
||||
// then
|
||||
expect(baselinePlatforms.length).toBe(4);
|
||||
expect(baselinePlatforms.map((p) => p.dir)).toContain("linux-x64-baseline");
|
||||
expect(baselinePlatforms.map((p) => p.dir)).toContain("linux-x64-musl-baseline");
|
||||
expect(baselinePlatforms.map((p) => p.dir)).toContain("darwin-x64-baseline");
|
||||
expect(baselinePlatforms.map((p) => p.dir)).toContain("windows-x64-baseline");
|
||||
});
|
||||
|
||||
it("has correct binary names for baseline platforms", async () => {
|
||||
// given
|
||||
const module = await import("./build-binaries.ts");
|
||||
const platforms = (module as { PLATFORMS: { dir: string; target: string; binary: string }[] }).PLATFORMS;
|
||||
|
||||
// when
|
||||
const windowsBaseline = platforms.find((p) => p.target === "bun-windows-x64-baseline");
|
||||
const linuxBaseline = platforms.find((p) => p.target === "bun-linux-x64-baseline");
|
||||
|
||||
// then
|
||||
expect(windowsBaseline?.binary).toBe("oh-my-opencode.exe");
|
||||
expect(linuxBaseline?.binary).toBe("oh-my-opencode");
|
||||
});
|
||||
|
||||
it("has descriptions mentioning no AVX2 for baseline platforms", async () => {
|
||||
// given
|
||||
const module = await import("./build-binaries.ts");
|
||||
const platforms = (module as { PLATFORMS: { target: string; description: string }[] }).PLATFORMS;
|
||||
|
||||
// when
|
||||
const baselinePlatforms = platforms.filter((p) => p.target.includes("baseline"));
|
||||
|
||||
// then
|
||||
for (const platform of baselinePlatforms) {
|
||||
expect(platform.description).toContain("no AVX2");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,14 +13,18 @@ interface PlatformTarget {
|
||||
description: string;
|
||||
}
|
||||
|
||||
const PLATFORMS: PlatformTarget[] = [
|
||||
export const PLATFORMS: PlatformTarget[] = [
|
||||
{ dir: "darwin-arm64", target: "bun-darwin-arm64", binary: "oh-my-opencode", description: "macOS ARM64" },
|
||||
{ dir: "darwin-x64", target: "bun-darwin-x64", binary: "oh-my-opencode", description: "macOS x64" },
|
||||
{ dir: "darwin-x64-baseline", target: "bun-darwin-x64-baseline", binary: "oh-my-opencode", description: "macOS x64 (no AVX2)" },
|
||||
{ dir: "linux-x64", target: "bun-linux-x64", binary: "oh-my-opencode", description: "Linux x64 (glibc)" },
|
||||
{ dir: "linux-x64-baseline", target: "bun-linux-x64-baseline", binary: "oh-my-opencode", description: "Linux x64 (glibc, no AVX2)" },
|
||||
{ dir: "linux-arm64", target: "bun-linux-arm64", binary: "oh-my-opencode", description: "Linux ARM64 (glibc)" },
|
||||
{ dir: "linux-x64-musl", target: "bun-linux-x64-musl", binary: "oh-my-opencode", description: "Linux x64 (musl)" },
|
||||
{ dir: "linux-x64-musl-baseline", target: "bun-linux-x64-musl-baseline", binary: "oh-my-opencode", description: "Linux x64 (musl, no AVX2)" },
|
||||
{ dir: "linux-arm64-musl", target: "bun-linux-arm64-musl", binary: "oh-my-opencode", description: "Linux ARM64 (musl)" },
|
||||
{ dir: "windows-x64", target: "bun-windows-x64", binary: "oh-my-opencode.exe", description: "Windows x64" },
|
||||
{ dir: "windows-x64-baseline", target: "bun-windows-x64-baseline", binary: "oh-my-opencode.exe", description: "Windows x64 (no AVX2)" },
|
||||
];
|
||||
|
||||
const ENTRY_POINT = "src/cli/index.ts";
|
||||
|
||||
@@ -815,6 +815,134 @@
|
||||
"created_at": "2026-01-25T03:13:52Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1084
|
||||
},
|
||||
{
|
||||
"name": "misyuari",
|
||||
"id": 12197761,
|
||||
"comment_id": 3798225767,
|
||||
"created_at": "2026-01-26T07:31:02Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1132
|
||||
},
|
||||
{
|
||||
"name": "boguan",
|
||||
"id": 3226538,
|
||||
"comment_id": 3798448537,
|
||||
"created_at": "2026-01-26T08:40:37Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1137
|
||||
},
|
||||
{
|
||||
"name": "boguan",
|
||||
"id": 3226538,
|
||||
"comment_id": 3798471978,
|
||||
"created_at": "2026-01-26T08:46:03Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1137
|
||||
},
|
||||
{
|
||||
"name": "Jeremy-Kr",
|
||||
"id": 110771206,
|
||||
"comment_id": 3799211732,
|
||||
"created_at": "2026-01-26T11:59:13Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1141
|
||||
},
|
||||
{
|
||||
"name": "orientpine",
|
||||
"id": 32758428,
|
||||
"comment_id": 3799897021,
|
||||
"created_at": "2026-01-26T14:30:33Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1145
|
||||
},
|
||||
{
|
||||
"name": "craftaholic",
|
||||
"id": 63741110,
|
||||
"comment_id": 3797014417,
|
||||
"created_at": "2026-01-25T17:52:34Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1110
|
||||
},
|
||||
{
|
||||
"name": "acamq",
|
||||
"id": 179265037,
|
||||
"comment_id": 3801038978,
|
||||
"created_at": "2026-01-26T18:20:17Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1151
|
||||
},
|
||||
{
|
||||
"name": "itsmylife44",
|
||||
"id": 34112129,
|
||||
"comment_id": 3802225779,
|
||||
"created_at": "2026-01-26T23:20:30Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1157
|
||||
},
|
||||
{
|
||||
"name": "ghtndl",
|
||||
"id": 117787238,
|
||||
"comment_id": 3802593326,
|
||||
"created_at": "2026-01-27T01:27:17Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1158
|
||||
},
|
||||
{
|
||||
"name": "alvinunreal",
|
||||
"id": 204474669,
|
||||
"comment_id": 3796402213,
|
||||
"created_at": "2026-01-25T10:26:58Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1100
|
||||
},
|
||||
{
|
||||
"name": "MoerAI",
|
||||
"id": 26067127,
|
||||
"comment_id": 3803968993,
|
||||
"created_at": "2026-01-27T09:00:57Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1172
|
||||
},
|
||||
{
|
||||
"name": "moha-abdi",
|
||||
"id": 83307623,
|
||||
"comment_id": 3804988070,
|
||||
"created_at": "2026-01-27T12:36:21Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1179
|
||||
},
|
||||
{
|
||||
"name": "zycaskevin",
|
||||
"id": 223135116,
|
||||
"comment_id": 3806137669,
|
||||
"created_at": "2026-01-27T16:20:38Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1184
|
||||
},
|
||||
{
|
||||
"name": "agno01",
|
||||
"id": 4479380,
|
||||
"comment_id": 3808373433,
|
||||
"created_at": "2026-01-28T01:02:02Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1188
|
||||
},
|
||||
{
|
||||
"name": "rooftop-Owl",
|
||||
"id": 254422872,
|
||||
"comment_id": 3809867225,
|
||||
"created_at": "2026-01-28T08:46:58Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1197
|
||||
},
|
||||
{
|
||||
"name": "youming-ai",
|
||||
"id": 173424537,
|
||||
"comment_id": 3811195276,
|
||||
"created_at": "2026-01-28T13:04:16Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1203
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -523,9 +523,6 @@ function buildDynamicOrchestratorPrompt(ctx?: OrchestratorContext): string {
|
||||
}
|
||||
|
||||
export function createAtlasAgent(ctx: OrchestratorContext): AgentConfig {
|
||||
if (!ctx.model) {
|
||||
throw new Error("createAtlasAgent requires a model in context")
|
||||
}
|
||||
const restrictions = createAgentToolRestrictions([
|
||||
"task",
|
||||
"call_omo_agent",
|
||||
@@ -534,7 +531,7 @@ export function createAtlasAgent(ctx: OrchestratorContext): AgentConfig {
|
||||
description:
|
||||
"Orchestrates work via delegate_task() to complete ALL tasks in a todo list until fully done",
|
||||
mode: "primary" as const,
|
||||
model: ctx.model,
|
||||
...(ctx.model ? { model: ctx.model } : {}),
|
||||
temperature: 0.1,
|
||||
prompt: buildDynamicOrchestratorPrompt(ctx),
|
||||
thinking: { type: "enabled", budgetTokens: 32000 },
|
||||
|
||||
@@ -230,6 +230,8 @@ call_omo_agent(subagent_type="librarian", prompt="Find OSS implementations of Z.
|
||||
- [Risk 2]: [Mitigation]
|
||||
|
||||
## Directives for Prometheus
|
||||
|
||||
### Core Directives
|
||||
- MUST: [Required action]
|
||||
- MUST: [Required action]
|
||||
- MUST NOT: [Forbidden action]
|
||||
@@ -237,6 +239,29 @@ call_omo_agent(subagent_type="librarian", prompt="Find OSS implementations of Z.
|
||||
- PATTERN: Follow \`[file:lines]\`
|
||||
- TOOL: Use \`[specific tool]\` for [purpose]
|
||||
|
||||
### QA/Acceptance Criteria Directives (MANDATORY)
|
||||
> **ZERO USER INTERVENTION PRINCIPLE**: All acceptance criteria MUST be executable by agents.
|
||||
|
||||
- MUST: Write acceptance criteria as executable commands (curl, bun test, playwright actions)
|
||||
- MUST: Include exact expected outputs, not vague descriptions
|
||||
- MUST: Specify verification tool for each deliverable type (playwright for UI, curl for API, etc.)
|
||||
- MUST NOT: Create criteria requiring "user manually tests..."
|
||||
- MUST NOT: Create criteria requiring "user visually confirms..."
|
||||
- MUST NOT: Create criteria requiring "user clicks/interacts..."
|
||||
- MUST NOT: Use placeholders without concrete examples (bad: "[endpoint]", good: "/api/users")
|
||||
|
||||
Example of GOOD acceptance criteria:
|
||||
\`\`\`
|
||||
curl -s http://localhost:3000/api/health | jq '.status'
|
||||
# Assert: Output is "ok"
|
||||
\`\`\`
|
||||
|
||||
Example of BAD acceptance criteria (FORBIDDEN):
|
||||
\`\`\`
|
||||
User opens browser and checks if the page loads correctly.
|
||||
User confirms the button works as expected.
|
||||
\`\`\`
|
||||
|
||||
## Recommended Approach
|
||||
[1-2 sentence summary of how to proceed]
|
||||
\`\`\`
|
||||
@@ -263,12 +288,16 @@ call_omo_agent(subagent_type="librarian", prompt="Find OSS implementations of Z.
|
||||
- Ask generic questions ("What's the scope?")
|
||||
- Proceed without addressing ambiguity
|
||||
- Make assumptions about user's codebase
|
||||
- Suggest acceptance criteria requiring user intervention ("user manually tests", "user confirms", "user clicks")
|
||||
- Leave QA/acceptance criteria vague or placeholder-heavy
|
||||
|
||||
**ALWAYS**:
|
||||
- Classify intent FIRST
|
||||
- Be specific ("Should this change UserService only, or also AuthService?")
|
||||
- Explore before asking (for Build/Research intents)
|
||||
- Provide actionable directives for Prometheus
|
||||
- Include QA automation directives in every output
|
||||
- Ensure acceptance criteria are agent-executable (commands, not human actions)
|
||||
`
|
||||
|
||||
const metisRestrictions = createAgentToolRestrictions([
|
||||
|
||||
@@ -863,6 +863,20 @@ Generate plan to: \`.sisyphus/plans/{name}.md\`
|
||||
\`\`\`markdown
|
||||
# {Plan Title}
|
||||
|
||||
## TL;DR
|
||||
|
||||
> **Quick Summary**: [1-2 sentences capturing the core objective and approach]
|
||||
>
|
||||
> **Deliverables**: [Bullet list of concrete outputs]
|
||||
> - [Output 1]
|
||||
> - [Output 2]
|
||||
>
|
||||
> **Estimated Effort**: [Quick | Short | Medium | Large | XL]
|
||||
> **Parallel Execution**: [YES - N waves | NO - sequential]
|
||||
> **Critical Path**: [Task X → Task Y → Task Z]
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
### Original Request
|
||||
@@ -939,53 +953,89 @@ Each TODO follows RED-GREEN-REFACTOR:
|
||||
- Example: Create \`src/__tests__/example.test.ts\`
|
||||
- Verify: \`bun test\` → 1 test passes
|
||||
|
||||
### If Manual QA Only
|
||||
### If Automated Verification Only (NO User Intervention)
|
||||
|
||||
**CRITICAL**: Without automated tests, manual verification MUST be exhaustive.
|
||||
> **CRITICAL PRINCIPLE: ZERO USER INTERVENTION**
|
||||
>
|
||||
> **NEVER** create acceptance criteria that require:
|
||||
> - "User manually tests..." / "사용자가 직접 테스트..."
|
||||
> - "User visually confirms..." / "사용자가 눈으로 확인..."
|
||||
> - "User interacts with..." / "사용자가 직접 조작..."
|
||||
> - "Ask user to verify..." / "사용자에게 확인 요청..."
|
||||
> - ANY step that requires a human to perform an action
|
||||
>
|
||||
> **ALL verification MUST be automated and executable by the agent.**
|
||||
> If a verification cannot be automated, find an automated alternative or explicitly note it as a known limitation.
|
||||
|
||||
Each TODO includes detailed verification procedures:
|
||||
Each TODO includes EXECUTABLE verification procedures that agents can run directly:
|
||||
|
||||
**By Deliverable Type:**
|
||||
|
||||
| Type | Verification Tool | Procedure |
|
||||
|------|------------------|-----------|
|
||||
| **Frontend/UI** | Playwright browser | Navigate, interact, screenshot |
|
||||
| **TUI/CLI** | interactive_bash (tmux) | Run command, verify output |
|
||||
| **API/Backend** | curl / httpie | Send request, verify response |
|
||||
| **Library/Module** | Node/Python REPL | Import, call, verify |
|
||||
| **Config/Infra** | Shell commands | Apply, verify state |
|
||||
| Type | Verification Tool | Automated Procedure |
|
||||
|------|------------------|---------------------|
|
||||
| **Frontend/UI** | Playwright browser via playwright skill | Agent navigates, clicks, screenshots, asserts DOM state |
|
||||
| **TUI/CLI** | interactive_bash (tmux) | Agent runs command, captures output, validates expected strings |
|
||||
| **API/Backend** | curl / httpie via Bash | Agent sends request, parses response, validates JSON fields |
|
||||
| **Library/Module** | Node/Python REPL via Bash | Agent imports, calls function, compares output |
|
||||
| **Config/Infra** | Shell commands via Bash | Agent applies config, runs state check, validates output |
|
||||
|
||||
**Evidence Required:**
|
||||
- Commands run with actual output
|
||||
- Screenshots for visual changes
|
||||
- Response bodies for API changes
|
||||
- Terminal output for CLI changes
|
||||
**Evidence Requirements (Agent-Executable):**
|
||||
- Command output captured and compared against expected patterns
|
||||
- Screenshots saved to .sisyphus/evidence/ for visual verification
|
||||
- JSON response fields validated with specific assertions
|
||||
- Exit codes checked (0 = success)
|
||||
|
||||
---
|
||||
|
||||
## Task Flow
|
||||
## Execution Strategy
|
||||
|
||||
### Parallel Execution Waves
|
||||
|
||||
> Maximize throughput by grouping independent tasks into parallel waves.
|
||||
> Each wave completes before the next begins.
|
||||
|
||||
\`\`\`
|
||||
Task 1 → Task 2 → Task 3
|
||||
↘ Task 4 (parallel)
|
||||
Wave 1 (Start Immediately):
|
||||
├── Task 1: [no dependencies]
|
||||
└── Task 5: [no dependencies]
|
||||
|
||||
Wave 2 (After Wave 1):
|
||||
├── Task 2: [depends: 1]
|
||||
├── Task 3: [depends: 1]
|
||||
└── Task 6: [depends: 5]
|
||||
|
||||
Wave 3 (After Wave 2):
|
||||
└── Task 4: [depends: 2, 3]
|
||||
|
||||
Critical Path: Task 1 → Task 2 → Task 4
|
||||
Parallel Speedup: ~40% faster than sequential
|
||||
\`\`\`
|
||||
|
||||
## Parallelization
|
||||
### Dependency Matrix
|
||||
|
||||
| Group | Tasks | Reason |
|
||||
|-------|-------|--------|
|
||||
| A | 2, 3 | Independent files |
|
||||
| Task | Depends On | Blocks | Can Parallelize With |
|
||||
|------|------------|--------|---------------------|
|
||||
| 1 | None | 2, 3 | 5 |
|
||||
| 2 | 1 | 4 | 3, 6 |
|
||||
| 3 | 1 | 4 | 2, 6 |
|
||||
| 4 | 2, 3 | None | None (final) |
|
||||
| 5 | None | 6 | 1 |
|
||||
| 6 | 5 | None | 2, 3 |
|
||||
|
||||
| Task | Depends On | Reason |
|
||||
|------|------------|--------|
|
||||
| 4 | 1 | Requires output from 1 |
|
||||
### Agent Dispatch Summary
|
||||
|
||||
| Wave | Tasks | Recommended Agents |
|
||||
|------|-------|-------------------|
|
||||
| 1 | 1, 5 | delegate_task(category="...", load_skills=[...], run_in_background=true) |
|
||||
| 2 | 2, 3, 6 | dispatch parallel after Wave 1 completes |
|
||||
| 3 | 4 | final integration task |
|
||||
|
||||
---
|
||||
|
||||
## TODOs
|
||||
|
||||
> Implementation + Test = ONE Task. Never separate.
|
||||
> Specify parallelizability for EVERY task.
|
||||
> EVERY task MUST have: Recommended Agent Profile + Parallelization info.
|
||||
|
||||
- [ ] 1. [Task Title]
|
||||
|
||||
@@ -996,7 +1046,21 @@ Task 1 → Task 2 → Task 3
|
||||
**Must NOT do**:
|
||||
- [Specific exclusions from guardrails]
|
||||
|
||||
**Parallelizable**: YES (with 3, 4) | NO (depends on 0)
|
||||
**Recommended Agent Profile**:
|
||||
> Select category + skills based on task domain. Justify each choice.
|
||||
- **Category**: \`[visual-engineering | ultrabrain | artistry | quick | unspecified-low | unspecified-high | writing]\`
|
||||
- Reason: [Why this category fits the task domain]
|
||||
- **Skills**: [\`skill-1\`, \`skill-2\`]
|
||||
- \`skill-1\`: [Why needed - domain overlap explanation]
|
||||
- \`skill-2\`: [Why needed - domain overlap explanation]
|
||||
- **Skills Evaluated but Omitted**:
|
||||
- \`omitted-skill\`: [Why domain doesn't overlap]
|
||||
|
||||
**Parallelization**:
|
||||
- **Can Run In Parallel**: YES | NO
|
||||
- **Parallel Group**: Wave N (with Tasks X, Y) | Sequential
|
||||
- **Blocks**: [Tasks that depend on this task completing]
|
||||
- **Blocked By**: [Tasks this depends on] | None (can start immediately)
|
||||
|
||||
**References** (CRITICAL - Be Exhaustive):
|
||||
|
||||
@@ -1029,53 +1093,76 @@ Task 1 → Task 2 → Task 3
|
||||
|
||||
**Acceptance Criteria**:
|
||||
|
||||
> CRITICAL: Acceptance = EXECUTION, not just "it should work".
|
||||
> The executor MUST run these commands and verify output.
|
||||
> **CRITICAL: AGENT-EXECUTABLE VERIFICATION ONLY**
|
||||
>
|
||||
> - Acceptance = EXECUTION by the agent, not "user checks if it works"
|
||||
> - Every criterion MUST be verifiable by running a command or using a tool
|
||||
> - NO steps like "user opens browser", "user clicks", "user confirms"
|
||||
> - If you write "[placeholder]" - REPLACE IT with actual values based on task context
|
||||
|
||||
**If TDD (tests enabled):**
|
||||
- [ ] Test file created: \`[path].test.ts\`
|
||||
- [ ] Test covers: [specific scenario]
|
||||
- [ ] \`bun test [file]\` → PASS (N tests, 0 failures)
|
||||
- [ ] Test file created: src/auth/login.test.ts
|
||||
- [ ] Test covers: successful login returns JWT token
|
||||
- [ ] bun test src/auth/login.test.ts → PASS (3 tests, 0 failures)
|
||||
|
||||
**Manual Execution Verification (ALWAYS include, even with tests):**
|
||||
**Automated Verification (ALWAYS include, choose by deliverable type):**
|
||||
|
||||
*Choose based on deliverable type:*
|
||||
**For Frontend/UI changes** (using playwright skill):
|
||||
\\\`\\\`\\\`
|
||||
# Agent executes via playwright browser automation:
|
||||
1. Navigate to: http://localhost:3000/login
|
||||
2. Fill: input[name="email"] with "test@example.com"
|
||||
3. Fill: input[name="password"] with "password123"
|
||||
4. Click: button[type="submit"]
|
||||
5. Wait for: selector ".dashboard-welcome" to be visible
|
||||
6. Assert: text "Welcome back" appears on page
|
||||
7. Screenshot: .sisyphus/evidence/task-1-login-success.png
|
||||
\\\`\\\`\\\`
|
||||
|
||||
**For Frontend/UI changes:**
|
||||
- [ ] Using playwright browser automation:
|
||||
- Navigate to: \`http://localhost:[port]/[path]\`
|
||||
- Action: [click X, fill Y, scroll to Z]
|
||||
- Verify: [visual element appears, animation completes, state changes]
|
||||
- Screenshot: Save evidence to \`.sisyphus/evidence/[task-id]-[step].png\`
|
||||
**For TUI/CLI changes** (using interactive_bash):
|
||||
\\\`\\\`\\\`
|
||||
# Agent executes via tmux session:
|
||||
1. Command: ./my-cli --config test.yaml
|
||||
2. Wait for: "Configuration loaded" in output
|
||||
3. Send keys: "q" to quit
|
||||
4. Assert: Exit code 0
|
||||
5. Assert: Output contains "Goodbye"
|
||||
\\\`\\\`\\\`
|
||||
|
||||
**For TUI/CLI changes:**
|
||||
- [ ] Using interactive_bash (tmux session):
|
||||
- Command: \`[exact command to run]\`
|
||||
- Input sequence: [if interactive, list inputs]
|
||||
- Expected output contains: \`[expected string or pattern]\`
|
||||
- Exit code: [0 for success, specific code if relevant]
|
||||
**For API/Backend changes** (using Bash curl):
|
||||
\\\`\\\`\\\`bash
|
||||
# Agent runs:
|
||||
curl -s -X POST http://localhost:8080/api/users \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"email":"new@test.com","name":"Test User"}' \\
|
||||
| jq '.id'
|
||||
# Assert: Returns non-empty UUID
|
||||
# Assert: HTTP status 201
|
||||
\\\`\\\`\\\`
|
||||
|
||||
**For API/Backend changes:**
|
||||
- [ ] Request: \`curl -X [METHOD] http://localhost:[port]/[endpoint] -H "Content-Type: application/json" -d '[body]'\`
|
||||
- [ ] Response status: [200/201/etc]
|
||||
- [ ] Response body contains: \`{"key": "expected_value"}\`
|
||||
**For Library/Module changes** (using Bash node/bun):
|
||||
\\\`\\\`\\\`bash
|
||||
# Agent runs:
|
||||
bun -e "import { validateEmail } from './src/utils/validate'; console.log(validateEmail('test@example.com'))"
|
||||
# Assert: Output is "true"
|
||||
|
||||
bun -e "import { validateEmail } from './src/utils/validate'; console.log(validateEmail('invalid'))"
|
||||
# Assert: Output is "false"
|
||||
\\\`\\\`\\\`
|
||||
|
||||
**For Library/Module changes:**
|
||||
- [ ] REPL verification:
|
||||
\`\`\`
|
||||
> import { [function] } from '[module]'
|
||||
> [function]([args])
|
||||
Expected: [output]
|
||||
\`\`\`
|
||||
**For Config/Infra changes** (using Bash):
|
||||
\\\`\\\`\\\`bash
|
||||
# Agent runs:
|
||||
docker compose up -d
|
||||
# Wait 5s for containers
|
||||
docker compose ps --format json | jq '.[].State'
|
||||
# Assert: All states are "running"
|
||||
\\\`\\\`\\\`
|
||||
|
||||
**For Config/Infra changes:**
|
||||
- [ ] Apply: \`[command to apply config]\`
|
||||
- [ ] Verify state: \`[command to check state]\` → \`[expected output]\`
|
||||
|
||||
**Evidence Required:**
|
||||
- [ ] Command output captured (copy-paste actual terminal output)
|
||||
- [ ] Screenshot saved (for visual changes)
|
||||
- [ ] Response body logged (for API changes)
|
||||
**Evidence to Capture:**
|
||||
- [ ] Terminal output from verification commands (actual output, not expected)
|
||||
- [ ] Screenshot files in .sisyphus/evidence/ for UI changes
|
||||
- [ ] JSON response bodies for API changes
|
||||
|
||||
**Commit**: YES | NO (groups with N)
|
||||
- Message: \`type(scope): desc\`
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, test, expect, beforeEach } from "bun:test"
|
||||
import { describe, test, expect, beforeEach, spyOn, afterEach } from "bun:test"
|
||||
import { createBuiltinAgents } from "./utils"
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import { clearSkillCache } from "../features/opencode-skill-loader/skill-content"
|
||||
import * as connectedProvidersCache from "../shared/connected-providers-cache"
|
||||
|
||||
const TEST_DEFAULT_MODEL = "anthropic/claude-opus-4-5"
|
||||
|
||||
@@ -46,17 +47,32 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
expect(agents.sisyphus.reasoningEffort).toBeUndefined()
|
||||
})
|
||||
|
||||
test("Oracle uses first fallback entry when no availableModels provided (no cache scenario)", async () => {
|
||||
// #given - no available models simulates CI without model cache
|
||||
test("Oracle uses connected provider when no availableModels but connected cache exists", async () => {
|
||||
// #given - connected providers cache exists with openai
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
|
||||
|
||||
// #then - uses first fallback entry (openai/gpt-5.2) instead of system default
|
||||
// #then - uses openai from connected cache
|
||||
expect(agents.oracle.model).toBe("openai/gpt-5.2")
|
||||
expect(agents.oracle.reasoningEffort).toBe("medium")
|
||||
expect(agents.oracle.textVerbosity).toBe("high")
|
||||
expect(agents.oracle.thinking).toBeUndefined()
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("Oracle created without model field when no cache exists (first run scenario)", async () => {
|
||||
// #given - no cache at all (first run)
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
|
||||
|
||||
// #then - oracle should be created with system default model (fallback to systemDefaultModel)
|
||||
expect(agents.oracle).toBeDefined()
|
||||
expect(agents.oracle.model).toBe(TEST_DEFAULT_MODEL)
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("Oracle with GPT model override has reasoningEffort, no thinking", async () => {
|
||||
@@ -106,6 +122,46 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("createBuiltinAgents without systemDefaultModel", () => {
|
||||
test("creates agents with connected provider when cache exists", async () => {
|
||||
// #given - connected providers cache exists
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], {}, undefined, undefined)
|
||||
|
||||
// #then - agents should use connected provider from fallback chain
|
||||
expect(agents.oracle).toBeDefined()
|
||||
expect(agents.oracle.model).toBe("openai/gpt-5.2")
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("agents NOT created when no cache and no systemDefaultModel (first run without defaults)", async () => {
|
||||
// #given - no cache and no system default
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], {}, undefined, undefined)
|
||||
|
||||
// #then - oracle should NOT be created (resolveModelWithFallback returns undefined)
|
||||
expect(agents.oracle).toBeUndefined()
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("sisyphus uses connected provider when cache exists", async () => {
|
||||
// #given - connected providers cache exists with anthropic
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["anthropic"])
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], {}, undefined, undefined)
|
||||
|
||||
// #then - sisyphus should use anthropic from connected cache
|
||||
expect(agents.sisyphus).toBeDefined()
|
||||
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-5")
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildAgent with category and skills", () => {
|
||||
const { buildAgent } = require("./utils")
|
||||
const TEST_MODEL = "anthropic/claude-opus-4-5"
|
||||
|
||||
@@ -151,10 +151,6 @@ export async function createBuiltinAgents(
|
||||
client?: any,
|
||||
browserProvider?: BrowserAutomationProvider
|
||||
): Promise<Record<string, AgentConfig>> {
|
||||
if (!systemDefaultModel) {
|
||||
throw new Error("createBuiltinAgents requires systemDefaultModel")
|
||||
}
|
||||
|
||||
const connectedProviders = readConnectedProvidersCache()
|
||||
const availableModels = client
|
||||
? await fetchAvailableModels(client, { connectedProviders: connectedProviders ?? undefined })
|
||||
@@ -201,13 +197,14 @@ export async function createBuiltinAgents(
|
||||
const override = findCaseInsensitive(agentOverrides, agentName)
|
||||
const requirement = AGENT_MODEL_REQUIREMENTS[agentName]
|
||||
|
||||
// Use resolver to determine model
|
||||
const { model, variant: resolvedVariant } = resolveModelWithFallback({
|
||||
const resolution = resolveModelWithFallback({
|
||||
userModel: override?.model,
|
||||
fallbackChain: requirement?.fallbackChain,
|
||||
availableModels,
|
||||
systemDefaultModel,
|
||||
})
|
||||
if (!resolution) continue
|
||||
const { model, variant: resolvedVariant } = resolution
|
||||
|
||||
let config = buildAgent(source, model, mergedCategories, gitMasterConfig, browserProvider)
|
||||
|
||||
@@ -243,72 +240,76 @@ export async function createBuiltinAgents(
|
||||
const sisyphusOverride = agentOverrides["sisyphus"]
|
||||
const sisyphusRequirement = AGENT_MODEL_REQUIREMENTS["sisyphus"]
|
||||
|
||||
// Use resolver to determine model
|
||||
const { model: sisyphusModel, variant: sisyphusResolvedVariant } = resolveModelWithFallback({
|
||||
const sisyphusResolution = resolveModelWithFallback({
|
||||
userModel: sisyphusOverride?.model,
|
||||
fallbackChain: sisyphusRequirement?.fallbackChain,
|
||||
availableModels,
|
||||
systemDefaultModel,
|
||||
})
|
||||
|
||||
let sisyphusConfig = createSisyphusAgent(
|
||||
sisyphusModel,
|
||||
availableAgents,
|
||||
undefined,
|
||||
availableSkills,
|
||||
availableCategories
|
||||
)
|
||||
|
||||
// Apply variant from override or resolved fallback chain
|
||||
if (sisyphusOverride?.variant) {
|
||||
sisyphusConfig = { ...sisyphusConfig, variant: sisyphusOverride.variant }
|
||||
} else if (sisyphusResolvedVariant) {
|
||||
sisyphusConfig = { ...sisyphusConfig, variant: sisyphusResolvedVariant }
|
||||
}
|
||||
if (sisyphusResolution) {
|
||||
const { model: sisyphusModel, variant: sisyphusResolvedVariant } = sisyphusResolution
|
||||
|
||||
if (directory && sisyphusConfig.prompt) {
|
||||
const envContext = createEnvContext()
|
||||
sisyphusConfig = { ...sisyphusConfig, prompt: sisyphusConfig.prompt + envContext }
|
||||
}
|
||||
let sisyphusConfig = createSisyphusAgent(
|
||||
sisyphusModel,
|
||||
availableAgents,
|
||||
undefined,
|
||||
availableSkills,
|
||||
availableCategories
|
||||
)
|
||||
|
||||
if (sisyphusOverride?.variant) {
|
||||
sisyphusConfig = { ...sisyphusConfig, variant: sisyphusOverride.variant }
|
||||
} else if (sisyphusResolvedVariant) {
|
||||
sisyphusConfig = { ...sisyphusConfig, variant: sisyphusResolvedVariant }
|
||||
}
|
||||
|
||||
if (sisyphusOverride) {
|
||||
sisyphusConfig = mergeAgentConfig(sisyphusConfig, sisyphusOverride)
|
||||
}
|
||||
if (directory && sisyphusConfig.prompt) {
|
||||
const envContext = createEnvContext()
|
||||
sisyphusConfig = { ...sisyphusConfig, prompt: sisyphusConfig.prompt + envContext }
|
||||
}
|
||||
|
||||
result["sisyphus"] = sisyphusConfig
|
||||
if (sisyphusOverride) {
|
||||
sisyphusConfig = mergeAgentConfig(sisyphusConfig, sisyphusOverride)
|
||||
}
|
||||
|
||||
result["sisyphus"] = sisyphusConfig
|
||||
}
|
||||
}
|
||||
|
||||
if (!disabledAgents.includes("atlas")) {
|
||||
const orchestratorOverride = agentOverrides["atlas"]
|
||||
const atlasRequirement = AGENT_MODEL_REQUIREMENTS["atlas"]
|
||||
|
||||
// Use resolver to determine model
|
||||
const { model: atlasModel, variant: atlasResolvedVariant } = resolveModelWithFallback({
|
||||
const atlasResolution = resolveModelWithFallback({
|
||||
userModel: orchestratorOverride?.model,
|
||||
fallbackChain: atlasRequirement?.fallbackChain,
|
||||
availableModels,
|
||||
systemDefaultModel,
|
||||
})
|
||||
|
||||
let orchestratorConfig = createAtlasAgent({
|
||||
model: atlasModel,
|
||||
availableAgents,
|
||||
availableSkills,
|
||||
userCategories: categories,
|
||||
})
|
||||
|
||||
// Apply variant from override or resolved fallback chain
|
||||
if (orchestratorOverride?.variant) {
|
||||
orchestratorConfig = { ...orchestratorConfig, variant: orchestratorOverride.variant }
|
||||
} else if (atlasResolvedVariant) {
|
||||
orchestratorConfig = { ...orchestratorConfig, variant: atlasResolvedVariant }
|
||||
}
|
||||
if (atlasResolution) {
|
||||
const { model: atlasModel, variant: atlasResolvedVariant } = atlasResolution
|
||||
|
||||
if (orchestratorOverride) {
|
||||
orchestratorConfig = mergeAgentConfig(orchestratorConfig, orchestratorOverride)
|
||||
}
|
||||
let orchestratorConfig = createAtlasAgent({
|
||||
model: atlasModel,
|
||||
availableAgents,
|
||||
availableSkills,
|
||||
userCategories: categories,
|
||||
})
|
||||
|
||||
if (orchestratorOverride?.variant) {
|
||||
orchestratorConfig = { ...orchestratorConfig, variant: orchestratorOverride.variant }
|
||||
} else if (atlasResolvedVariant) {
|
||||
orchestratorConfig = { ...orchestratorConfig, variant: atlasResolvedVariant }
|
||||
}
|
||||
|
||||
result["atlas"] = orchestratorConfig
|
||||
if (orchestratorOverride) {
|
||||
orchestratorConfig = mergeAgentConfig(orchestratorConfig, orchestratorOverride)
|
||||
}
|
||||
|
||||
result["atlas"] = orchestratorConfig
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
17
src/cli/index.test.ts
Normal file
17
src/cli/index.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import packageJson from "../../package.json" with { type: "json" }
|
||||
|
||||
describe("CLI version", () => {
|
||||
it("reads version from package.json as valid semver", () => {
|
||||
//#given
|
||||
const semverRegex = /^\d+\.\d+\.\d+(-[\w.]+)?$/
|
||||
|
||||
//#when
|
||||
const version = packageJson.version
|
||||
|
||||
//#then
|
||||
expect(version).toMatch(semverRegex)
|
||||
expect(typeof version).toBe("string")
|
||||
expect(version.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -31,8 +31,18 @@ export async function run(options: RunOptions): Promise<number> {
|
||||
}
|
||||
|
||||
try {
|
||||
// Support custom OpenCode server port via environment variable
|
||||
// This allows Open Agent and other orchestrators to run multiple
|
||||
// concurrent missions without port conflicts
|
||||
const serverPort = process.env.OPENCODE_SERVER_PORT
|
||||
? parseInt(process.env.OPENCODE_SERVER_PORT, 10)
|
||||
: undefined
|
||||
const serverHostname = process.env.OPENCODE_SERVER_HOSTNAME || undefined
|
||||
|
||||
const { client, server } = await createOpencode({
|
||||
signal: abortController.signal,
|
||||
...(serverPort && !isNaN(serverPort) ? { port: serverPort } : {}),
|
||||
...(serverHostname ? { hostname: serverHostname } : {}),
|
||||
})
|
||||
|
||||
const cleanup = () => {
|
||||
|
||||
@@ -116,6 +116,19 @@ export const AgentOverrideConfigSchema = z.object({
|
||||
.regex(/^#[0-9A-Fa-f]{6}$/)
|
||||
.optional(),
|
||||
permission: AgentPermissionSchema.optional(),
|
||||
/** Maximum tokens for response. Passed directly to OpenCode SDK. */
|
||||
maxTokens: z.number().optional(),
|
||||
/** Extended thinking configuration (Anthropic). Overrides category and default settings. */
|
||||
thinking: z.object({
|
||||
type: z.enum(["enabled", "disabled"]),
|
||||
budgetTokens: z.number().optional(),
|
||||
}).optional(),
|
||||
/** Reasoning effort level (OpenAI). Overrides category and default settings. */
|
||||
reasoningEffort: z.enum(["low", "medium", "high", "xhigh"]).optional(),
|
||||
/** Text verbosity level. */
|
||||
textVerbosity: z.enum(["low", "medium", "high"]).optional(),
|
||||
/** Provider-specific options. Passed directly to OpenCode SDK. */
|
||||
providerOptions: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
|
||||
export const AgentOverridesSchema = z.object({
|
||||
@@ -300,13 +313,14 @@ export const GitMasterConfigSchema = z.object({
|
||||
include_co_authored_by: z.boolean().default(true),
|
||||
})
|
||||
|
||||
export const BrowserAutomationProviderSchema = z.enum(["playwright", "agent-browser"])
|
||||
export const BrowserAutomationProviderSchema = z.enum(["playwright", "agent-browser", "dev-browser"])
|
||||
|
||||
export const BrowserAutomationConfigSchema = z.object({
|
||||
/**
|
||||
* Browser automation provider to use for the "playwright" skill.
|
||||
* - "playwright": Uses Playwright MCP server (@playwright/mcp) - default
|
||||
* - "agent-browser": Uses Vercel's agent-browser CLI (requires: bun add -g agent-browser)
|
||||
* - "dev-browser": Uses dev-browser skill with persistent browser state
|
||||
*/
|
||||
provider: BrowserAutomationProviderSchema.default("playwright"),
|
||||
})
|
||||
@@ -326,6 +340,29 @@ export const TmuxConfigSchema = z.object({
|
||||
main_pane_min_width: z.number().min(40).default(120),
|
||||
agent_pane_min_width: z.number().min(20).default(40),
|
||||
})
|
||||
|
||||
export const SisyphusTasksConfigSchema = z.object({
|
||||
/** Enable Sisyphus Tasks system (default: false) */
|
||||
enabled: z.boolean().default(false),
|
||||
/** Storage path for tasks (default: .sisyphus/tasks) */
|
||||
storage_path: z.string().default(".sisyphus/tasks"),
|
||||
/** Enable Claude Code path compatibility mode */
|
||||
claude_code_compat: z.boolean().default(false),
|
||||
})
|
||||
|
||||
export const SisyphusSwarmConfigSchema = z.object({
|
||||
/** Enable Sisyphus Swarm system (default: false) */
|
||||
enabled: z.boolean().default(false),
|
||||
/** Storage path for teams (default: .sisyphus/teams) */
|
||||
storage_path: z.string().default(".sisyphus/teams"),
|
||||
/** UI mode: toast notifications, tmux panes, or both */
|
||||
ui_mode: z.enum(["toast", "tmux", "both"]).default("toast"),
|
||||
})
|
||||
|
||||
export const SisyphusConfigSchema = z.object({
|
||||
tasks: SisyphusTasksConfigSchema.optional(),
|
||||
swarm: SisyphusSwarmConfigSchema.optional(),
|
||||
})
|
||||
export const OhMyOpenCodeConfigSchema = z.object({
|
||||
$schema: z.string().optional(),
|
||||
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
|
||||
@@ -347,6 +384,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
|
||||
git_master: GitMasterConfigSchema.optional(),
|
||||
browser_automation_engine: BrowserAutomationConfigSchema.optional(),
|
||||
tmux: TmuxConfigSchema.optional(),
|
||||
sisyphus: SisyphusConfigSchema.optional(),
|
||||
})
|
||||
|
||||
export type OhMyOpenCodeConfig = z.infer<typeof OhMyOpenCodeConfigSchema>
|
||||
@@ -373,5 +411,8 @@ export type BrowserAutomationProvider = z.infer<typeof BrowserAutomationProvider
|
||||
export type BrowserAutomationConfig = z.infer<typeof BrowserAutomationConfigSchema>
|
||||
export type TmuxConfig = z.infer<typeof TmuxConfigSchema>
|
||||
export type TmuxLayout = z.infer<typeof TmuxLayoutSchema>
|
||||
export type SisyphusTasksConfig = z.infer<typeof SisyphusTasksConfigSchema>
|
||||
export type SisyphusSwarmConfig = z.infer<typeof SisyphusSwarmConfigSchema>
|
||||
export type SisyphusConfig = z.infer<typeof SisyphusConfigSchema>
|
||||
|
||||
export { AnyMcpNameSchema, type AnyMcpName, McpNameSchema, type McpName } from "../mcp/types"
|
||||
|
||||
@@ -224,7 +224,10 @@ export class BackgroundManager {
|
||||
body: {
|
||||
parentID: input.parentSessionID,
|
||||
title: `Background: ${input.description}`,
|
||||
},
|
||||
permission: [
|
||||
{ permission: "question", action: "deny" as const, pattern: "*" },
|
||||
],
|
||||
} as any,
|
||||
query: {
|
||||
directory: parentDirectory,
|
||||
},
|
||||
@@ -294,17 +297,26 @@ export class BackgroundManager {
|
||||
|
||||
// Use prompt() instead of promptAsync() to properly initialize agent loop (fire-and-forget)
|
||||
// Include model if caller provided one (e.g., from Sisyphus category configs)
|
||||
// IMPORTANT: variant must be a top-level field in the body, NOT nested inside model
|
||||
// OpenCode's PromptInput schema expects: { model: { providerID, modelID }, variant: "max" }
|
||||
const launchModel = input.model
|
||||
? { providerID: input.model.providerID, modelID: input.model.modelID }
|
||||
: undefined
|
||||
const launchVariant = input.model?.variant
|
||||
|
||||
this.client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: input.agent,
|
||||
...(input.model ? { model: input.model } : {}),
|
||||
...(launchModel ? { model: launchModel } : {}),
|
||||
...(launchVariant ? { variant: launchVariant } : {}),
|
||||
system: input.skillContent,
|
||||
tools: {
|
||||
...getAgentToolRestrictions(input.agent),
|
||||
task: false,
|
||||
delegate_task: false,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
},
|
||||
parts: [{ type: "text", text: input.prompt }],
|
||||
},
|
||||
@@ -541,16 +553,24 @@ export class BackgroundManager {
|
||||
|
||||
// Use prompt() instead of promptAsync() to properly initialize agent loop
|
||||
// Include model if task has one (preserved from original launch with category config)
|
||||
// variant must be top-level in body, not nested inside model (OpenCode PromptInput schema)
|
||||
const resumeModel = existingTask.model
|
||||
? { providerID: existingTask.model.providerID, modelID: existingTask.model.modelID }
|
||||
: undefined
|
||||
const resumeVariant = existingTask.model?.variant
|
||||
|
||||
this.client.session.prompt({
|
||||
path: { id: existingTask.sessionID },
|
||||
body: {
|
||||
agent: existingTask.agent,
|
||||
...(existingTask.model ? { model: existingTask.model } : {}),
|
||||
...(resumeModel ? { model: resumeModel } : {}),
|
||||
...(resumeVariant ? { variant: resumeVariant } : {}),
|
||||
tools: {
|
||||
...getAgentToolRestrictions(existingTask.agent),
|
||||
task: false,
|
||||
delegate_task: false,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
},
|
||||
parts: [{ type: "text", text: input.prompt }],
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, test, expect, beforeEach } from "bun:test"
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
||||
import {
|
||||
setSessionAgent,
|
||||
getSessionAgent,
|
||||
@@ -13,9 +13,11 @@ describe("claude-code-session-state", () => {
|
||||
beforeEach(() => {
|
||||
// #given - clean state before each test
|
||||
_resetForTesting()
|
||||
clearSessionAgent("test-session-1")
|
||||
clearSessionAgent("test-session-2")
|
||||
clearSessionAgent("test-prometheus-session")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// #then - cleanup after each test to prevent pollution
|
||||
_resetForTesting()
|
||||
})
|
||||
|
||||
describe("setSessionAgent", () => {
|
||||
@@ -92,9 +94,9 @@ describe("claude-code-session-state", () => {
|
||||
expect(getMainSessionID()).toBe(mainID)
|
||||
})
|
||||
|
||||
test.skip("should return undefined when not set", () => {
|
||||
// #given - not set
|
||||
// TODO: Fix flaky test - parallel test execution causes state pollution
|
||||
test("should return undefined when not set", () => {
|
||||
// #given - explicit reset to ensure clean state (parallel test isolation)
|
||||
_resetForTesting()
|
||||
// #then
|
||||
expect(getMainSessionID()).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ export function getMainSessionID(): string | undefined {
|
||||
export function _resetForTesting(): void {
|
||||
_mainSessionID = undefined
|
||||
subagentSessions.clear()
|
||||
sessionAgentMap.clear()
|
||||
}
|
||||
|
||||
const sessionAgentMap = new Map<string, string>()
|
||||
|
||||
@@ -128,8 +128,15 @@ $ARGUMENTS
|
||||
}
|
||||
}
|
||||
|
||||
function parseAllowedTools(allowedTools: string | undefined): string[] | undefined {
|
||||
function parseAllowedTools(allowedTools: string | string[] | undefined): string[] | undefined {
|
||||
if (!allowedTools) return undefined
|
||||
|
||||
// Handle YAML array format: already parsed as string[]
|
||||
if (Array.isArray(allowedTools)) {
|
||||
return allowedTools.map(t => t.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
// Handle space-separated string format: "Read Write Edit Bash"
|
||||
return allowedTools.split(/\s+/).filter(Boolean)
|
||||
}
|
||||
|
||||
|
||||
@@ -268,6 +268,123 @@ Skill body.
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("allowed-tools parsing", () => {
|
||||
it("parses space-separated allowed-tools string", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: space-separated-tools
|
||||
description: Skill with space-separated allowed-tools
|
||||
allowed-tools: Read Write Edit Bash
|
||||
---
|
||||
Skill body.
|
||||
`
|
||||
createTestSkill("space-separated-tools", skillContent)
|
||||
|
||||
// #when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = await discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "space-separated-tools")
|
||||
|
||||
// #then
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill?.allowedTools).toEqual(["Read", "Write", "Edit", "Bash"])
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
|
||||
it("parses YAML inline array allowed-tools", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: yaml-inline-array
|
||||
description: Skill with YAML inline array allowed-tools
|
||||
allowed-tools: [Read, Write, Edit, Bash]
|
||||
---
|
||||
Skill body.
|
||||
`
|
||||
createTestSkill("yaml-inline-array", skillContent)
|
||||
|
||||
// #when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = await discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "yaml-inline-array")
|
||||
|
||||
// #then
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill?.allowedTools).toEqual(["Read", "Write", "Edit", "Bash"])
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
|
||||
it("parses YAML multi-line array allowed-tools", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: yaml-multiline-array
|
||||
description: Skill with YAML multi-line array allowed-tools
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Write
|
||||
- Edit
|
||||
- Bash
|
||||
---
|
||||
Skill body.
|
||||
`
|
||||
createTestSkill("yaml-multiline-array", skillContent)
|
||||
|
||||
// #when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = await discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "yaml-multiline-array")
|
||||
|
||||
// #then
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill?.allowedTools).toEqual(["Read", "Write", "Edit", "Bash"])
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
|
||||
it("returns undefined for skill without allowed-tools", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: no-allowed-tools
|
||||
description: Skill without allowed-tools field
|
||||
---
|
||||
Skill body.
|
||||
`
|
||||
createTestSkill("no-allowed-tools", skillContent)
|
||||
|
||||
// #when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = await discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "no-allowed-tools")
|
||||
|
||||
// #then
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill?.allowedTools).toBeUndefined()
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -50,8 +50,15 @@ async function loadMcpJsonFromDir(skillDir: string): Promise<SkillMcpConfig | un
|
||||
return undefined
|
||||
}
|
||||
|
||||
function parseAllowedTools(allowedTools: string | undefined): string[] | undefined {
|
||||
function parseAllowedTools(allowedTools: string | string[] | undefined): string[] | undefined {
|
||||
if (!allowedTools) return undefined
|
||||
|
||||
// Handle YAML array format: already parsed as string[]
|
||||
if (Array.isArray(allowedTools)) {
|
||||
return allowedTools.map(t => t.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
// Handle space-separated string format: "Read Write Edit Bash"
|
||||
return allowedTools.split(/\s+/).filter(Boolean)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,14 @@ import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import { sanitizeModelField } from "../../shared/model-sanitizer"
|
||||
import { deepMerge } from "../../shared/deep-merge"
|
||||
|
||||
function parseAllowedToolsFromMetadata(allowedTools: string | string[] | undefined): string[] | undefined {
|
||||
if (!allowedTools) return undefined
|
||||
if (Array.isArray(allowedTools)) {
|
||||
return allowedTools.map(t => t.trim()).filter(Boolean)
|
||||
}
|
||||
return allowedTools.split(/\s+/).filter(Boolean)
|
||||
}
|
||||
|
||||
const SCOPE_PRIORITY: Record<SkillScope, number> = {
|
||||
builtin: 1,
|
||||
config: 2,
|
||||
@@ -119,7 +127,7 @@ $ARGUMENTS
|
||||
}
|
||||
|
||||
const allowedTools = entry["allowed-tools"] ||
|
||||
(fileMetadata["allowed-tools"] ? fileMetadata["allowed-tools"].split(/\s+/).filter(Boolean) : undefined)
|
||||
(fileMetadata["allowed-tools"] ? parseAllowedToolsFromMetadata(fileMetadata["allowed-tools"]) : undefined)
|
||||
|
||||
return {
|
||||
name,
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface SkillMetadata {
|
||||
license?: string
|
||||
compatibility?: string
|
||||
metadata?: Record<string, string>
|
||||
"allowed-tools"?: string
|
||||
"allowed-tools"?: string | string[]
|
||||
mcp?: SkillMcpConfig
|
||||
}
|
||||
|
||||
|
||||
112
src/features/sisyphus-swarm/mailbox/types.test.ts
Normal file
112
src/features/sisyphus-swarm/mailbox/types.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import {
|
||||
MailboxMessageSchema,
|
||||
PermissionRequestSchema,
|
||||
PermissionResponseSchema,
|
||||
ShutdownRequestSchema,
|
||||
TaskAssignmentSchema,
|
||||
JoinRequestSchema,
|
||||
ProtocolMessageSchema,
|
||||
} from "./types"
|
||||
|
||||
describe("MailboxMessageSchema", () => {
|
||||
//#given a valid mailbox message
|
||||
//#when parsing
|
||||
//#then it should succeed
|
||||
it("parses valid message", () => {
|
||||
const msg = {
|
||||
from: "agent-001",
|
||||
text: '{"type":"idle_notification"}',
|
||||
timestamp: "2026-01-27T10:00:00Z",
|
||||
read: false,
|
||||
}
|
||||
expect(MailboxMessageSchema.safeParse(msg).success).toBe(true)
|
||||
})
|
||||
|
||||
//#given a message with optional color
|
||||
//#when parsing
|
||||
//#then it should succeed
|
||||
it("parses message with color", () => {
|
||||
const msg = {
|
||||
from: "agent-001",
|
||||
text: "{}",
|
||||
timestamp: "2026-01-27T10:00:00Z",
|
||||
color: "blue",
|
||||
read: true,
|
||||
}
|
||||
expect(MailboxMessageSchema.safeParse(msg).success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("ProtocolMessageSchema", () => {
|
||||
//#given permission_request message
|
||||
//#when parsing
|
||||
//#then it should succeed
|
||||
it("parses permission_request", () => {
|
||||
const msg = {
|
||||
type: "permission_request",
|
||||
requestId: "req-123",
|
||||
toolName: "Bash",
|
||||
input: { command: "rm -rf /" },
|
||||
agentId: "agent-001",
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
expect(PermissionRequestSchema.safeParse(msg).success).toBe(true)
|
||||
})
|
||||
|
||||
//#given permission_response message
|
||||
//#when parsing
|
||||
//#then it should succeed
|
||||
it("parses permission_response", () => {
|
||||
const approved = {
|
||||
type: "permission_response",
|
||||
requestId: "req-123",
|
||||
decision: "approved",
|
||||
updatedInput: { command: "ls" },
|
||||
}
|
||||
expect(PermissionResponseSchema.safeParse(approved).success).toBe(true)
|
||||
|
||||
const rejected = {
|
||||
type: "permission_response",
|
||||
requestId: "req-123",
|
||||
decision: "rejected",
|
||||
feedback: "Too dangerous",
|
||||
}
|
||||
expect(PermissionResponseSchema.safeParse(rejected).success).toBe(true)
|
||||
})
|
||||
|
||||
//#given shutdown_request message
|
||||
//#when parsing
|
||||
//#then it should succeed
|
||||
it("parses shutdown messages", () => {
|
||||
const request = { type: "shutdown_request" }
|
||||
expect(ShutdownRequestSchema.safeParse(request).success).toBe(true)
|
||||
})
|
||||
|
||||
//#given task_assignment message
|
||||
//#when parsing
|
||||
//#then it should succeed
|
||||
it("parses task_assignment", () => {
|
||||
const msg = {
|
||||
type: "task_assignment",
|
||||
taskId: "1",
|
||||
subject: "Fix bug",
|
||||
description: "Fix the auth bug",
|
||||
assignedBy: "team-lead",
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
expect(TaskAssignmentSchema.safeParse(msg).success).toBe(true)
|
||||
})
|
||||
|
||||
//#given join_request message
|
||||
//#when parsing
|
||||
//#then it should succeed
|
||||
it("parses join_request", () => {
|
||||
const msg = {
|
||||
type: "join_request",
|
||||
agentName: "new-agent",
|
||||
sessionId: "sess-123",
|
||||
}
|
||||
expect(JoinRequestSchema.safeParse(msg).success).toBe(true)
|
||||
})
|
||||
})
|
||||
153
src/features/sisyphus-swarm/mailbox/types.ts
Normal file
153
src/features/sisyphus-swarm/mailbox/types.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const MailboxMessageSchema = z.object({
|
||||
from: z.string(),
|
||||
text: z.string(),
|
||||
timestamp: z.string(),
|
||||
color: z.string().optional(),
|
||||
read: z.boolean(),
|
||||
})
|
||||
|
||||
export type MailboxMessage = z.infer<typeof MailboxMessageSchema>
|
||||
|
||||
export const PermissionRequestSchema = z.object({
|
||||
type: z.literal("permission_request"),
|
||||
requestId: z.string(),
|
||||
toolName: z.string(),
|
||||
input: z.unknown(),
|
||||
agentId: z.string(),
|
||||
timestamp: z.number(),
|
||||
})
|
||||
|
||||
export type PermissionRequest = z.infer<typeof PermissionRequestSchema>
|
||||
|
||||
export const PermissionResponseSchema = z.object({
|
||||
type: z.literal("permission_response"),
|
||||
requestId: z.string(),
|
||||
decision: z.enum(["approved", "rejected"]),
|
||||
updatedInput: z.unknown().optional(),
|
||||
feedback: z.string().optional(),
|
||||
permissionUpdates: z.unknown().optional(),
|
||||
})
|
||||
|
||||
export type PermissionResponse = z.infer<typeof PermissionResponseSchema>
|
||||
|
||||
export const ShutdownRequestSchema = z.object({
|
||||
type: z.literal("shutdown_request"),
|
||||
})
|
||||
|
||||
export type ShutdownRequest = z.infer<typeof ShutdownRequestSchema>
|
||||
|
||||
export const ShutdownApprovedSchema = z.object({
|
||||
type: z.literal("shutdown_approved"),
|
||||
})
|
||||
|
||||
export type ShutdownApproved = z.infer<typeof ShutdownApprovedSchema>
|
||||
|
||||
export const ShutdownRejectedSchema = z.object({
|
||||
type: z.literal("shutdown_rejected"),
|
||||
reason: z.string().optional(),
|
||||
})
|
||||
|
||||
export type ShutdownRejected = z.infer<typeof ShutdownRejectedSchema>
|
||||
|
||||
export const TaskAssignmentSchema = z.object({
|
||||
type: z.literal("task_assignment"),
|
||||
taskId: z.string(),
|
||||
subject: z.string(),
|
||||
description: z.string(),
|
||||
assignedBy: z.string(),
|
||||
timestamp: z.number(),
|
||||
})
|
||||
|
||||
export type TaskAssignment = z.infer<typeof TaskAssignmentSchema>
|
||||
|
||||
export const TaskCompletedSchema = z.object({
|
||||
type: z.literal("task_completed"),
|
||||
taskId: z.string(),
|
||||
agentId: z.string(),
|
||||
timestamp: z.number(),
|
||||
})
|
||||
|
||||
export type TaskCompleted = z.infer<typeof TaskCompletedSchema>
|
||||
|
||||
export const IdleNotificationSchema = z.object({
|
||||
type: z.literal("idle_notification"),
|
||||
})
|
||||
|
||||
export type IdleNotification = z.infer<typeof IdleNotificationSchema>
|
||||
|
||||
export const JoinRequestSchema = z.object({
|
||||
type: z.literal("join_request"),
|
||||
agentName: z.string(),
|
||||
sessionId: z.string(),
|
||||
})
|
||||
|
||||
export type JoinRequest = z.infer<typeof JoinRequestSchema>
|
||||
|
||||
export const JoinApprovedSchema = z.object({
|
||||
type: z.literal("join_approved"),
|
||||
agentName: z.string(),
|
||||
teamName: z.string(),
|
||||
})
|
||||
|
||||
export type JoinApproved = z.infer<typeof JoinApprovedSchema>
|
||||
|
||||
export const JoinRejectedSchema = z.object({
|
||||
type: z.literal("join_rejected"),
|
||||
reason: z.string().optional(),
|
||||
})
|
||||
|
||||
export type JoinRejected = z.infer<typeof JoinRejectedSchema>
|
||||
|
||||
export const PlanApprovalRequestSchema = z.object({
|
||||
type: z.literal("plan_approval_request"),
|
||||
requestId: z.string(),
|
||||
plan: z.string(),
|
||||
agentId: z.string(),
|
||||
})
|
||||
|
||||
export type PlanApprovalRequest = z.infer<typeof PlanApprovalRequestSchema>
|
||||
|
||||
export const PlanApprovalResponseSchema = z.object({
|
||||
type: z.literal("plan_approval_response"),
|
||||
requestId: z.string(),
|
||||
decision: z.enum(["approved", "rejected"]),
|
||||
feedback: z.string().optional(),
|
||||
})
|
||||
|
||||
export type PlanApprovalResponse = z.infer<typeof PlanApprovalResponseSchema>
|
||||
|
||||
export const ModeSetRequestSchema = z.object({
|
||||
type: z.literal("mode_set_request"),
|
||||
mode: z.enum(["acceptEdits", "bypassPermissions", "default", "delegate", "dontAsk", "plan"]),
|
||||
})
|
||||
|
||||
export type ModeSetRequest = z.infer<typeof ModeSetRequestSchema>
|
||||
|
||||
export const TeamPermissionUpdateSchema = z.object({
|
||||
type: z.literal("team_permission_update"),
|
||||
permissions: z.record(z.string(), z.unknown()),
|
||||
})
|
||||
|
||||
export type TeamPermissionUpdate = z.infer<typeof TeamPermissionUpdateSchema>
|
||||
|
||||
export const ProtocolMessageSchema = z.discriminatedUnion("type", [
|
||||
PermissionRequestSchema,
|
||||
PermissionResponseSchema,
|
||||
ShutdownRequestSchema,
|
||||
ShutdownApprovedSchema,
|
||||
ShutdownRejectedSchema,
|
||||
TaskAssignmentSchema,
|
||||
TaskCompletedSchema,
|
||||
IdleNotificationSchema,
|
||||
JoinRequestSchema,
|
||||
JoinApprovedSchema,
|
||||
JoinRejectedSchema,
|
||||
PlanApprovalRequestSchema,
|
||||
PlanApprovalResponseSchema,
|
||||
ModeSetRequestSchema,
|
||||
TeamPermissionUpdateSchema,
|
||||
])
|
||||
|
||||
export type ProtocolMessage = z.infer<typeof ProtocolMessageSchema>
|
||||
178
src/features/sisyphus-tasks/storage.test.ts
Normal file
178
src/features/sisyphus-tasks/storage.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { join } from "path"
|
||||
import { mkdirSync, rmSync, existsSync, writeFileSync, readFileSync } from "fs"
|
||||
import { z } from "zod"
|
||||
import {
|
||||
getTaskDir,
|
||||
getTaskPath,
|
||||
getTeamDir,
|
||||
getInboxPath,
|
||||
ensureDir,
|
||||
readJsonSafe,
|
||||
writeJsonAtomic,
|
||||
} from "./storage"
|
||||
|
||||
const TEST_DIR = join(import.meta.dirname, ".test-storage")
|
||||
|
||||
describe("Storage Utilities", () => {
|
||||
beforeEach(() => {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("getTaskDir", () => {
|
||||
//#given default config (no claude_code_compat)
|
||||
//#when getting task directory
|
||||
//#then it should return .sisyphus/tasks/{listId}
|
||||
it("returns sisyphus path by default", () => {
|
||||
const config = { sisyphus: { tasks: { storage_path: ".sisyphus/tasks" } } }
|
||||
const result = getTaskDir("list-123", config as any)
|
||||
expect(result).toContain(".sisyphus/tasks/list-123")
|
||||
})
|
||||
|
||||
//#given claude_code_compat enabled
|
||||
//#when getting task directory
|
||||
//#then it should return Claude Code path
|
||||
it("returns claude code path when compat enabled", () => {
|
||||
const config = {
|
||||
sisyphus: {
|
||||
tasks: {
|
||||
storage_path: ".sisyphus/tasks",
|
||||
claude_code_compat: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
const result = getTaskDir("list-123", config as any)
|
||||
expect(result).toContain(".cache/claude-code/tasks/list-123")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getTaskPath", () => {
|
||||
//#given list and task IDs
|
||||
//#when getting task path
|
||||
//#then it should return path to task JSON file
|
||||
it("returns path to task JSON", () => {
|
||||
const config = { sisyphus: { tasks: { storage_path: ".sisyphus/tasks" } } }
|
||||
const result = getTaskPath("list-123", "1", config as any)
|
||||
expect(result).toContain("list-123/1.json")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getTeamDir", () => {
|
||||
//#given team name and default config
|
||||
//#when getting team directory
|
||||
//#then it should return .sisyphus/teams/{teamName}
|
||||
it("returns sisyphus team path", () => {
|
||||
const config = { sisyphus: { swarm: { storage_path: ".sisyphus/teams" } } }
|
||||
const result = getTeamDir("my-team", config as any)
|
||||
expect(result).toContain(".sisyphus/teams/my-team")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getInboxPath", () => {
|
||||
//#given team and agent names
|
||||
//#when getting inbox path
|
||||
//#then it should return path to inbox JSON file
|
||||
it("returns path to inbox JSON", () => {
|
||||
const config = { sisyphus: { swarm: { storage_path: ".sisyphus/teams" } } }
|
||||
const result = getInboxPath("my-team", "agent-001", config as any)
|
||||
expect(result).toContain("my-team/inboxes/agent-001.json")
|
||||
})
|
||||
})
|
||||
|
||||
describe("ensureDir", () => {
|
||||
//#given a non-existent directory path
|
||||
//#when calling ensureDir
|
||||
//#then it should create the directory
|
||||
it("creates directory if not exists", () => {
|
||||
const dirPath = join(TEST_DIR, "new-dir", "nested")
|
||||
ensureDir(dirPath)
|
||||
expect(existsSync(dirPath)).toBe(true)
|
||||
})
|
||||
|
||||
//#given an existing directory
|
||||
//#when calling ensureDir
|
||||
//#then it should not throw
|
||||
it("does not throw for existing directory", () => {
|
||||
const dirPath = join(TEST_DIR, "existing")
|
||||
mkdirSync(dirPath, { recursive: true })
|
||||
expect(() => ensureDir(dirPath)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("readJsonSafe", () => {
|
||||
//#given a valid JSON file matching schema
|
||||
//#when reading with readJsonSafe
|
||||
//#then it should return parsed object
|
||||
it("reads and parses valid JSON", () => {
|
||||
const testSchema = z.object({ name: z.string(), value: z.number() })
|
||||
const filePath = join(TEST_DIR, "test.json")
|
||||
writeFileSync(filePath, JSON.stringify({ name: "test", value: 42 }))
|
||||
|
||||
const result = readJsonSafe(filePath, testSchema)
|
||||
expect(result).toEqual({ name: "test", value: 42 })
|
||||
})
|
||||
|
||||
//#given a non-existent file
|
||||
//#when reading with readJsonSafe
|
||||
//#then it should return null
|
||||
it("returns null for non-existent file", () => {
|
||||
const testSchema = z.object({ name: z.string() })
|
||||
const result = readJsonSafe(join(TEST_DIR, "missing.json"), testSchema)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
//#given invalid JSON content
|
||||
//#when reading with readJsonSafe
|
||||
//#then it should return null
|
||||
it("returns null for invalid JSON", () => {
|
||||
const testSchema = z.object({ name: z.string() })
|
||||
const filePath = join(TEST_DIR, "invalid.json")
|
||||
writeFileSync(filePath, "not valid json")
|
||||
|
||||
const result = readJsonSafe(filePath, testSchema)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
//#given JSON that doesn't match schema
|
||||
//#when reading with readJsonSafe
|
||||
//#then it should return null
|
||||
it("returns null for schema mismatch", () => {
|
||||
const testSchema = z.object({ name: z.string(), required: z.number() })
|
||||
const filePath = join(TEST_DIR, "mismatch.json")
|
||||
writeFileSync(filePath, JSON.stringify({ name: "test" }))
|
||||
|
||||
const result = readJsonSafe(filePath, testSchema)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("writeJsonAtomic", () => {
|
||||
//#given data to write
|
||||
//#when calling writeJsonAtomic
|
||||
//#then it should write to file atomically
|
||||
it("writes JSON atomically", () => {
|
||||
const filePath = join(TEST_DIR, "atomic.json")
|
||||
const data = { key: "value", number: 123 }
|
||||
|
||||
writeJsonAtomic(filePath, data)
|
||||
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
expect(JSON.parse(content)).toEqual(data)
|
||||
})
|
||||
|
||||
//#given a deeply nested path
|
||||
//#when calling writeJsonAtomic
|
||||
//#then it should create parent directories
|
||||
it("creates parent directories", () => {
|
||||
const filePath = join(TEST_DIR, "deep", "nested", "file.json")
|
||||
writeJsonAtomic(filePath, { test: true })
|
||||
|
||||
expect(existsSync(filePath)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
82
src/features/sisyphus-tasks/storage.ts
Normal file
82
src/features/sisyphus-tasks/storage.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { join, dirname } from "path"
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from "fs"
|
||||
import { homedir } from "os"
|
||||
import type { z } from "zod"
|
||||
import type { OhMyOpenCodeConfig } from "../../config/schema"
|
||||
|
||||
export function getTaskDir(listId: string, config: Partial<OhMyOpenCodeConfig>): string {
|
||||
const tasksConfig = config.sisyphus?.tasks
|
||||
|
||||
if (tasksConfig?.claude_code_compat) {
|
||||
return join(homedir(), ".cache", "claude-code", "tasks", listId)
|
||||
}
|
||||
|
||||
const storagePath = tasksConfig?.storage_path ?? ".sisyphus/tasks"
|
||||
return join(process.cwd(), storagePath, listId)
|
||||
}
|
||||
|
||||
export function getTaskPath(listId: string, taskId: string, config: Partial<OhMyOpenCodeConfig>): string {
|
||||
return join(getTaskDir(listId, config), `${taskId}.json`)
|
||||
}
|
||||
|
||||
export function getTeamDir(teamName: string, config: Partial<OhMyOpenCodeConfig>): string {
|
||||
const swarmConfig = config.sisyphus?.swarm
|
||||
|
||||
if (swarmConfig?.storage_path?.includes("claude")) {
|
||||
return join(homedir(), ".claude", "teams", teamName)
|
||||
}
|
||||
|
||||
const storagePath = swarmConfig?.storage_path ?? ".sisyphus/teams"
|
||||
return join(process.cwd(), storagePath, teamName)
|
||||
}
|
||||
|
||||
export function getInboxPath(teamName: string, agentName: string, config: Partial<OhMyOpenCodeConfig>): string {
|
||||
return join(getTeamDir(teamName, config), "inboxes", `${agentName}.json`)
|
||||
}
|
||||
|
||||
export function ensureDir(dirPath: string): void {
|
||||
if (!existsSync(dirPath)) {
|
||||
mkdirSync(dirPath, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
export function readJsonSafe<T>(filePath: string, schema: z.ZodType<T>): T | null {
|
||||
try {
|
||||
if (!existsSync(filePath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
const parsed = JSON.parse(content)
|
||||
const result = schema.safeParse(parsed)
|
||||
|
||||
if (!result.success) {
|
||||
return null
|
||||
}
|
||||
|
||||
return result.data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function writeJsonAtomic(filePath: string, data: unknown): void {
|
||||
const dir = dirname(filePath)
|
||||
ensureDir(dir)
|
||||
|
||||
const tempPath = `${filePath}.tmp.${Date.now()}`
|
||||
|
||||
try {
|
||||
writeFileSync(tempPath, JSON.stringify(data, null, 2), "utf-8")
|
||||
renameSync(tempPath, filePath)
|
||||
} catch (error) {
|
||||
try {
|
||||
if (existsSync(tempPath)) {
|
||||
unlinkSync(tempPath)
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
82
src/features/sisyphus-tasks/types.test.ts
Normal file
82
src/features/sisyphus-tasks/types.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { TaskSchema, TaskStatusSchema, type Task } from "./types"
|
||||
|
||||
describe("TaskSchema", () => {
|
||||
//#given a valid task object
|
||||
//#when parsing with TaskSchema
|
||||
//#then it should succeed
|
||||
it("parses valid task object", () => {
|
||||
const validTask = {
|
||||
id: "1",
|
||||
subject: "Fix authentication bug",
|
||||
description: "Users report 401 errors",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
}
|
||||
|
||||
const result = TaskSchema.safeParse(validTask)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
//#given a task with all optional fields
|
||||
//#when parsing with TaskSchema
|
||||
//#then it should succeed
|
||||
it("parses task with optional fields", () => {
|
||||
const taskWithOptionals = {
|
||||
id: "2",
|
||||
subject: "Add unit tests",
|
||||
description: "Write tests for auth module",
|
||||
activeForm: "Adding unit tests",
|
||||
owner: "agent-001",
|
||||
status: "in_progress",
|
||||
blocks: ["3"],
|
||||
blockedBy: ["1"],
|
||||
metadata: { priority: "high", labels: ["bug"] },
|
||||
}
|
||||
|
||||
const result = TaskSchema.safeParse(taskWithOptionals)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
//#given an invalid status value
|
||||
//#when parsing with TaskSchema
|
||||
//#then it should fail
|
||||
it("rejects invalid status", () => {
|
||||
const invalidTask = {
|
||||
id: "1",
|
||||
subject: "Test",
|
||||
description: "Test",
|
||||
status: "invalid_status",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
}
|
||||
|
||||
const result = TaskSchema.safeParse(invalidTask)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
//#given missing required fields
|
||||
//#when parsing with TaskSchema
|
||||
//#then it should fail
|
||||
it("rejects missing required fields", () => {
|
||||
const invalidTask = {
|
||||
id: "1",
|
||||
// missing subject, description, status, blocks, blockedBy
|
||||
}
|
||||
|
||||
const result = TaskSchema.safeParse(invalidTask)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("TaskStatusSchema", () => {
|
||||
//#given valid status values
|
||||
//#when parsing
|
||||
//#then all should succeed
|
||||
it("accepts valid statuses", () => {
|
||||
expect(TaskStatusSchema.safeParse("pending").success).toBe(true)
|
||||
expect(TaskStatusSchema.safeParse("in_progress").success).toBe(true)
|
||||
expect(TaskStatusSchema.safeParse("completed").success).toBe(true)
|
||||
})
|
||||
})
|
||||
41
src/features/sisyphus-tasks/types.ts
Normal file
41
src/features/sisyphus-tasks/types.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const TaskStatusSchema = z.enum(["pending", "in_progress", "completed"])
|
||||
export type TaskStatus = z.infer<typeof TaskStatusSchema>
|
||||
|
||||
export const TaskSchema = z.object({
|
||||
id: z.string(),
|
||||
subject: z.string(),
|
||||
description: z.string(),
|
||||
activeForm: z.string().optional(),
|
||||
owner: z.string().optional(),
|
||||
status: TaskStatusSchema,
|
||||
blocks: z.array(z.string()),
|
||||
blockedBy: z.array(z.string()),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
|
||||
export type Task = z.infer<typeof TaskSchema>
|
||||
|
||||
export const TaskCreateInputSchema = z.object({
|
||||
subject: z.string().describe("Task title"),
|
||||
description: z.string().describe("Detailed description"),
|
||||
activeForm: z.string().optional().describe("Text shown when in progress"),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
|
||||
export type TaskCreateInput = z.infer<typeof TaskCreateInputSchema>
|
||||
|
||||
export const TaskUpdateInputSchema = z.object({
|
||||
taskId: z.string().describe("Task ID to update"),
|
||||
subject: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
activeForm: z.string().optional(),
|
||||
status: z.enum(["pending", "in_progress", "completed", "deleted"]).optional(),
|
||||
addBlocks: z.array(z.string()).optional().describe("Task IDs this task will block"),
|
||||
addBlockedBy: z.array(z.string()).optional().describe("Task IDs that block this task"),
|
||||
owner: z.string().optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
|
||||
export type TaskUpdateInput = z.infer<typeof TaskUpdateInputSchema>
|
||||
@@ -66,6 +66,20 @@ describe("atlas hook", () => {
|
||||
})
|
||||
|
||||
describe("tool.execute.after handler", () => {
|
||||
test("should handle undefined output gracefully (issue #1035)", async () => {
|
||||
// #given - hook and undefined output (e.g., from /review command)
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
|
||||
// #when - calling with undefined output
|
||||
const result = await hook["tool.execute.after"](
|
||||
{ tool: "delegate_task", sessionID: "session-123" },
|
||||
undefined as unknown as { title: string; output: string; metadata: Record<string, unknown> }
|
||||
)
|
||||
|
||||
// #then - returns undefined without throwing
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
test("should ignore non-delegate_task tools", async () => {
|
||||
// #given - hook and non-delegate_task tool
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
@@ -396,9 +410,9 @@ describe("atlas hook", () => {
|
||||
)
|
||||
|
||||
// #then
|
||||
expect(output.output).toContain("DELEGATION REQUIRED")
|
||||
expect(output.output).toContain("ORCHESTRATOR, not an IMPLEMENTER")
|
||||
expect(output.output).toContain("delegate_task")
|
||||
expect(output.output).toContain("delegate_task")
|
||||
})
|
||||
|
||||
test("should append delegation reminder when orchestrator edits outside .sisyphus/", async () => {
|
||||
@@ -417,7 +431,7 @@ describe("atlas hook", () => {
|
||||
)
|
||||
|
||||
// #then
|
||||
expect(output.output).toContain("DELEGATION REQUIRED")
|
||||
expect(output.output).toContain("ORCHESTRATOR, not an IMPLEMENTER")
|
||||
})
|
||||
|
||||
test("should NOT append reminder when orchestrator writes inside .sisyphus/", async () => {
|
||||
@@ -438,7 +452,7 @@ describe("atlas hook", () => {
|
||||
|
||||
// #then
|
||||
expect(output.output).toBe(originalOutput)
|
||||
expect(output.output).not.toContain("DELEGATION REQUIRED")
|
||||
expect(output.output).not.toContain("ORCHESTRATOR, not an IMPLEMENTER")
|
||||
})
|
||||
|
||||
test("should NOT append reminder when non-orchestrator writes outside .sisyphus/", async () => {
|
||||
@@ -462,7 +476,7 @@ describe("atlas hook", () => {
|
||||
|
||||
// #then
|
||||
expect(output.output).toBe(originalOutput)
|
||||
expect(output.output).not.toContain("DELEGATION REQUIRED")
|
||||
expect(output.output).not.toContain("ORCHESTRATOR, not an IMPLEMENTER")
|
||||
|
||||
cleanupMessageStorage(nonOrchestratorSession)
|
||||
})
|
||||
@@ -526,7 +540,7 @@ describe("atlas hook", () => {
|
||||
|
||||
// #then
|
||||
expect(output.output).toBe(originalOutput)
|
||||
expect(output.output).not.toContain("DELEGATION REQUIRED")
|
||||
expect(output.output).not.toContain("ORCHESTRATOR, not an IMPLEMENTER")
|
||||
})
|
||||
|
||||
test("should NOT append reminder when orchestrator writes inside .sisyphus with mixed separators", async () => {
|
||||
@@ -547,7 +561,7 @@ describe("atlas hook", () => {
|
||||
|
||||
// #then
|
||||
expect(output.output).toBe(originalOutput)
|
||||
expect(output.output).not.toContain("DELEGATION REQUIRED")
|
||||
expect(output.output).not.toContain("ORCHESTRATOR, not an IMPLEMENTER")
|
||||
})
|
||||
|
||||
test("should NOT append reminder for absolute Windows path inside .sisyphus\\", async () => {
|
||||
@@ -568,7 +582,7 @@ describe("atlas hook", () => {
|
||||
|
||||
// #then
|
||||
expect(output.output).toBe(originalOutput)
|
||||
expect(output.output).not.toContain("DELEGATION REQUIRED")
|
||||
expect(output.output).not.toContain("ORCHESTRATOR, not an IMPLEMENTER")
|
||||
})
|
||||
|
||||
test("should append reminder for Windows path outside .sisyphus\\", async () => {
|
||||
@@ -587,7 +601,7 @@ describe("atlas hook", () => {
|
||||
)
|
||||
|
||||
// #then
|
||||
expect(output.output).toContain("DELEGATION REQUIRED")
|
||||
expect(output.output).toContain("ORCHESTRATOR, not an IMPLEMENTER")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -636,7 +650,7 @@ describe("atlas hook", () => {
|
||||
expect(mockInput._promptMock).toHaveBeenCalled()
|
||||
const callArgs = mockInput._promptMock.mock.calls[0][0]
|
||||
expect(callArgs.path.id).toBe(MAIN_SESSION_ID)
|
||||
expect(callArgs.body.parts[0].text).toContain("BOULDER CONTINUATION")
|
||||
expect(callArgs.body.parts[0].text).toContain("incomplete tasks")
|
||||
expect(callArgs.body.parts[0].text).toContain("2 remaining")
|
||||
})
|
||||
|
||||
|
||||
@@ -663,6 +663,11 @@ export function createAtlasHook(
|
||||
input: ToolExecuteAfterInput,
|
||||
output: ToolExecuteAfterOutput
|
||||
): Promise<void> => {
|
||||
// Guard against undefined output (e.g., from /review command - see issue #1035)
|
||||
if (!output) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isCallerOrchestrator(input.sessionID)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -170,6 +170,20 @@ export function getCachedVersion(): string | null {
|
||||
log("[auto-update-checker] Failed to resolve version from current directory:", err)
|
||||
}
|
||||
|
||||
// Fallback for compiled binaries (npm global install)
|
||||
// process.execPath points to the actual binary location
|
||||
try {
|
||||
const execDir = path.dirname(fs.realpathSync(process.execPath))
|
||||
const pkgPath = findPackageJsonUp(execDir)
|
||||
if (pkgPath) {
|
||||
const content = fs.readFileSync(pkgPath, "utf-8")
|
||||
const pkg = JSON.parse(content) as PackageJson
|
||||
if (pkg.version) return pkg.version
|
||||
}
|
||||
} catch (err) {
|
||||
log("[auto-update-checker] Failed to resolve version from execPath:", err)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -237,6 +237,11 @@ export function createClaudeCodeHooksHook(
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { title: string; output: string; metadata: unknown }
|
||||
): Promise<void> => {
|
||||
// Guard against undefined output (e.g., from /review command - see issue #1035)
|
||||
if (!output) {
|
||||
return
|
||||
}
|
||||
|
||||
const claudeConfig = await loadClaudeHooksConfig()
|
||||
const extendedConfig = await loadPluginExtendedConfig()
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ export async function executePostToolUseHooks(
|
||||
|
||||
if (result.exitCode === 0 && result.stdout) {
|
||||
try {
|
||||
const output = JSON.parse(result.stdout) as PostToolUseOutput
|
||||
const output = JSON.parse(result.stdout || "{}") as PostToolUseOutput
|
||||
if (output.decision === "block") {
|
||||
return {
|
||||
block: true,
|
||||
|
||||
@@ -73,7 +73,7 @@ export async function executePreCompactHooks(
|
||||
|
||||
if (result.stdout) {
|
||||
try {
|
||||
const output = JSON.parse(result.stdout) as PreCompactOutput
|
||||
const output = JSON.parse(result.stdout || "{}") as PreCompactOutput
|
||||
|
||||
if (output.hookSpecificOutput?.additionalContext) {
|
||||
collectedContext.push(...output.hookSpecificOutput.additionalContext)
|
||||
|
||||
@@ -117,7 +117,7 @@ export async function executePreToolUseHooks(
|
||||
|
||||
if (result.stdout) {
|
||||
try {
|
||||
const output = JSON.parse(result.stdout) as PreToolUseOutput
|
||||
const output = JSON.parse(result.stdout || "{}") as PreToolUseOutput
|
||||
|
||||
// Handle deprecated decision/reason fields (Claude Code backward compat)
|
||||
let decision: PermissionDecision | undefined
|
||||
|
||||
@@ -93,7 +93,7 @@ export async function executeStopHooks(
|
||||
|
||||
if (result.stdout) {
|
||||
try {
|
||||
const output = JSON.parse(result.stdout) as StopOutput
|
||||
const output = JSON.parse(result.stdout || "{}") as StopOutput
|
||||
if (output.stop_hook_active !== undefined) {
|
||||
stopHookActiveState.set(ctx.sessionId, output.stop_hook_active)
|
||||
}
|
||||
|
||||
102
src/hooks/compaction-context-injector/index.test.ts
Normal file
102
src/hooks/compaction-context-injector/index.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it, mock, beforeEach } from "bun:test"
|
||||
|
||||
// Mock dependencies before importing
|
||||
const mockInjectHookMessage = mock(() => true)
|
||||
mock.module("../../features/hook-message-injector", () => ({
|
||||
injectHookMessage: mockInjectHookMessage,
|
||||
}))
|
||||
|
||||
mock.module("../../shared/logger", () => ({
|
||||
log: () => {},
|
||||
}))
|
||||
|
||||
mock.module("../../shared/system-directive", () => ({
|
||||
createSystemDirective: (type: string) => `[DIRECTIVE:${type}]`,
|
||||
SystemDirectiveTypes: {
|
||||
TODO_CONTINUATION: "TODO CONTINUATION",
|
||||
RALPH_LOOP: "RALPH LOOP",
|
||||
BOULDER_CONTINUATION: "BOULDER CONTINUATION",
|
||||
DELEGATION_REQUIRED: "DELEGATION REQUIRED",
|
||||
SINGLE_TASK_ONLY: "SINGLE TASK ONLY",
|
||||
COMPACTION_CONTEXT: "COMPACTION CONTEXT",
|
||||
CONTEXT_WINDOW_MONITOR: "CONTEXT WINDOW MONITOR",
|
||||
PROMETHEUS_READ_ONLY: "PROMETHEUS READ-ONLY",
|
||||
},
|
||||
}))
|
||||
|
||||
import { createCompactionContextInjector } from "./index"
|
||||
import type { SummarizeContext } from "./index"
|
||||
|
||||
describe("createCompactionContextInjector", () => {
|
||||
beforeEach(() => {
|
||||
mockInjectHookMessage.mockClear()
|
||||
})
|
||||
|
||||
describe("Agent Verification State preservation", () => {
|
||||
it("includes Agent Verification State section in compaction prompt", async () => {
|
||||
// given
|
||||
const injector = createCompactionContextInjector()
|
||||
const context: SummarizeContext = {
|
||||
sessionID: "test-session",
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-5",
|
||||
usageRatio: 0.85,
|
||||
directory: "/test/dir",
|
||||
}
|
||||
|
||||
// when
|
||||
await injector(context)
|
||||
|
||||
// then
|
||||
expect(mockInjectHookMessage).toHaveBeenCalledTimes(1)
|
||||
const calls = mockInjectHookMessage.mock.calls as unknown as [string, string, unknown][]
|
||||
const injectedPrompt = calls[0]?.[1] ?? ""
|
||||
expect(injectedPrompt).toContain("Agent Verification State")
|
||||
expect(injectedPrompt).toContain("Current Agent")
|
||||
expect(injectedPrompt).toContain("Verification Progress")
|
||||
})
|
||||
|
||||
it("includes Momus-specific context for reviewer agents", async () => {
|
||||
// given
|
||||
const injector = createCompactionContextInjector()
|
||||
const context: SummarizeContext = {
|
||||
sessionID: "test-session",
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-5",
|
||||
usageRatio: 0.9,
|
||||
directory: "/test/dir",
|
||||
}
|
||||
|
||||
// when
|
||||
await injector(context)
|
||||
|
||||
// then
|
||||
const calls = mockInjectHookMessage.mock.calls as unknown as [string, string, unknown][]
|
||||
const injectedPrompt = calls[0]?.[1] ?? ""
|
||||
expect(injectedPrompt).toContain("Previous Rejections")
|
||||
expect(injectedPrompt).toContain("Acceptance Status")
|
||||
expect(injectedPrompt).toContain("reviewer agents")
|
||||
})
|
||||
|
||||
it("preserves file verification progress in compaction prompt", async () => {
|
||||
// given
|
||||
const injector = createCompactionContextInjector()
|
||||
const context: SummarizeContext = {
|
||||
sessionID: "test-session",
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-5",
|
||||
usageRatio: 0.95,
|
||||
directory: "/test/dir",
|
||||
}
|
||||
|
||||
// when
|
||||
await injector(context)
|
||||
|
||||
// then
|
||||
const calls = mockInjectHookMessage.mock.calls as unknown as [string, string, unknown][]
|
||||
const injectedPrompt = calls[0]?.[1] ?? ""
|
||||
expect(injectedPrompt).toContain("Pending Verifications")
|
||||
expect(injectedPrompt).toContain("Files already verified")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -45,6 +45,15 @@ When summarizing this session, you MUST include the following sections in your s
|
||||
- User's explicit restrictions or preferences
|
||||
- Anti-patterns identified during the session
|
||||
|
||||
## 7. Agent Verification State (Critical for Reviewers)
|
||||
- **Current Agent**: What agent is running (momus, oracle, etc.)
|
||||
- **Verification Progress**: Files already verified/validated
|
||||
- **Pending Verifications**: Files still needing verification
|
||||
- **Previous Rejections**: If reviewer agent, what was rejected and why
|
||||
- **Acceptance Status**: Current state of review process
|
||||
|
||||
This section is CRITICAL for reviewer agents (momus, oracle) to maintain continuity.
|
||||
|
||||
This context is critical for maintaining continuity after compaction.
|
||||
`
|
||||
|
||||
|
||||
@@ -33,3 +33,4 @@ export { createStartWorkHook } from "./start-work";
|
||||
export { createAtlasHook } from "./atlas";
|
||||
export { createDelegateTaskRetryHook } from "./delegate-task-retry";
|
||||
export { createQuestionLabelTruncatorHook } from "./question-label-truncator";
|
||||
export { createSubagentQuestionBlockerHook } from "./subagent-question-blocker";
|
||||
|
||||
@@ -49,13 +49,87 @@ You ARE the planner. Your job: create bulletproof work plans.
|
||||
- External library APIs and constraints
|
||||
- Similar implementations in OSS (via librarian)
|
||||
|
||||
**NEVER plan blind. Context first, plan second.**`
|
||||
**NEVER plan blind. Context first, plan second.**
|
||||
|
||||
---
|
||||
|
||||
## MANDATORY OUTPUT: PARALLEL TASK GRAPH + TODO LIST
|
||||
|
||||
**YOUR PRIMARY OUTPUT IS A PARALLEL EXECUTION TASK GRAPH.**
|
||||
|
||||
When you finalize a plan, you MUST structure it for maximum parallel execution:
|
||||
|
||||
### 1. Parallel Execution Waves (REQUIRED)
|
||||
|
||||
Analyze task dependencies and group independent tasks into parallel waves:
|
||||
|
||||
\`\`\`
|
||||
Wave 1 (Start Immediately - No Dependencies):
|
||||
├── Task 1: [description] → category: X, skills: [a, b]
|
||||
└── Task 4: [description] → category: Y, skills: [c]
|
||||
|
||||
Wave 2 (After Wave 1 Completes):
|
||||
├── Task 2: [depends: 1] → category: X, skills: [a]
|
||||
├── Task 3: [depends: 1] → category: Z, skills: [d]
|
||||
└── Task 5: [depends: 4] → category: Y, skills: [c]
|
||||
|
||||
Wave 3 (After Wave 2 Completes):
|
||||
└── Task 6: [depends: 2, 3] → category: X, skills: [a, b]
|
||||
|
||||
Critical Path: Task 1 → Task 2 → Task 6
|
||||
Estimated Parallel Speedup: ~40% faster than sequential
|
||||
\`\`\`
|
||||
|
||||
### 2. Dependency Matrix (REQUIRED)
|
||||
|
||||
| Task | Depends On | Blocks | Can Parallelize With |
|
||||
|------|------------|--------|---------------------|
|
||||
| 1 | None | 2, 3 | 4 |
|
||||
| 2 | 1 | 6 | 3, 5 |
|
||||
| 3 | 1 | 6 | 2, 5 |
|
||||
| 4 | None | 5 | 1 |
|
||||
| 5 | 4 | None | 2, 3 |
|
||||
| 6 | 2, 3 | None | None (final) |
|
||||
|
||||
### 3. TODO List Structure (REQUIRED)
|
||||
|
||||
Each TODO item MUST include:
|
||||
|
||||
\`\`\`markdown
|
||||
- [ ] N. [Task Title]
|
||||
|
||||
**What to do**: [Clear steps]
|
||||
|
||||
**Dependencies**: [Task numbers this depends on] | None
|
||||
**Blocks**: [Task numbers that depend on this]
|
||||
**Parallel Group**: Wave N (with Tasks X, Y)
|
||||
|
||||
**Recommended Agent Profile**:
|
||||
- **Category**: \`[visual-engineering | ultrabrain | artistry | quick | unspecified-low | unspecified-high | writing]\`
|
||||
- **Skills**: [\`skill-1\`, \`skill-2\`]
|
||||
|
||||
**Acceptance Criteria**: [Verifiable conditions]
|
||||
\`\`\`
|
||||
|
||||
### 4. Agent Dispatch Summary (REQUIRED)
|
||||
|
||||
| Wave | Tasks | Dispatch Command |
|
||||
|------|-------|------------------|
|
||||
| 1 | 1, 4 | \`delegate_task(category="...", load_skills=[...], run_in_background=true)\` × 2 |
|
||||
| 2 | 2, 3, 5 | \`delegate_task(...)\` × 3 after Wave 1 completes |
|
||||
| 3 | 6 | \`delegate_task(...)\` final integration |
|
||||
|
||||
**WHY PARALLEL TASK GRAPH IS MANDATORY:**
|
||||
- Orchestrator (Sisyphus) executes tasks in parallel waves
|
||||
- Independent tasks run simultaneously via background agents
|
||||
- Proper dependency tracking prevents race conditions
|
||||
- Category + skills ensure optimal model routing per task`
|
||||
|
||||
/**
|
||||
* Determines if the agent is a planner-type agent.
|
||||
* Planner agents should NOT be told to call plan agent (they ARE the planner).
|
||||
*/
|
||||
function isPlannerAgent(agentName?: string): boolean {
|
||||
export function isPlannerAgent(agentName?: string): boolean {
|
||||
if (!agentName) return false
|
||||
const lowerName = agentName.toLowerCase()
|
||||
return lowerName.includes("prometheus") || lowerName.includes("planner") || lowerName === "plan"
|
||||
@@ -166,34 +240,142 @@ delegate_task(agent="oracle", prompt="Review my approach: [describe plan]")
|
||||
YOU MUST LEVERAGE ALL AVAILABLE AGENTS / **CATEGORY + SKILLS** TO THEIR FULLEST POTENTIAL.
|
||||
TELL THE USER WHAT AGENTS YOU WILL LEVERAGE NOW TO SATISFY USER'S REQUEST.
|
||||
|
||||
## AGENTS / **CATEGORY + SKILLS** UTILIZATION PRINCIPLES (by capability, not by name)
|
||||
- **Codebase Exploration**: Spawn exploration agents using BACKGROUND TASKS for file patterns, internal implementations, project structure
|
||||
- **Documentation & References**: Use librarian-type agents via BACKGROUND TASKS for API references, examples, external library docs
|
||||
- **Planning & Strategy**: NEVER plan yourself - ALWAYS spawn the Plan agent for work breakdown
|
||||
- MUST invoke: \`delegate_task(subagent_type="plan", prompt="<gathered context + user request>")\`
|
||||
- In your prompt to the Plan agent, ASK it to recommend which CATEGORY + SKILLS / AGENTS to leverage for implementation.
|
||||
- IF IMPLEMENT TASK, MUST ADD TODO NOW: "Consult Plan agent via delegate_task(subagent_type='plan') for work breakdown with category + skills recommendations"
|
||||
- **High-IQ Reasoning**: Leverage specialized agents for architecture decisions, code review, strategic planning
|
||||
- **SPECIAL TASKS COVERED WITH CATEGORY + LOAD_SKILLS**: Delegate to specialized agents with category+skills for design and implementation, as following guide:
|
||||
- CATEGORY + SKILL GUIDE
|
||||
- MUST PASS \`load_skills\` FOR REQUIRED_SKILLS. MUST USE \`load_skills\` FOR REQUIRED_SKILLS.
|
||||
- Simple project setup -> delegate_task(category="unspecified-low", load_skills=[{project-setup-skill}])
|
||||
- Super Complex Server Workflow Implementation -> delegate_task(category="ultrabrain", load_skills=["terraform-master"], ...)
|
||||
- Web Frontend Component Writing -> delegate_task(category="visual-engineering", load_skills=["frontend-ui-ux", "playwright"], ...)
|
||||
## MANDATORY: PLAN AGENT INVOCATION (NON-NEGOTIABLE)
|
||||
|
||||
## EXECUTION RULES
|
||||
- **TODO**: Track EVERY step. Mark complete IMMEDIATELY after each.
|
||||
- **PARALLEL**: Fire independent agent calls simultaneously via delegate_task(background=true) - NEVER wait sequentially.
|
||||
- **BACKGROUND FIRST**: Use delegate_task for exploration/research agents (10+ concurrent if needed).
|
||||
- **VERIFY**: Re-read request after completion. Check ALL requirements met before reporting done.
|
||||
- **DELEGATE**: Don't do everything yourself - orchestrate specialized agents for their strengths.
|
||||
- **CATEGORY + LOAD_SKILLS**
|
||||
**YOU MUST ALWAYS INVOKE THE PLAN AGENT FOR ANY NON-TRIVIAL TASK.**
|
||||
|
||||
## WORKFLOW
|
||||
1. Analyze the request and identify required capabilities
|
||||
2. Spawn exploration/librarian agents via delegate_task(background=true) in PARALLEL (10+ if needed)
|
||||
3. Spawn Plan agent: \`delegate_task(subagent_type="plan", prompt="<context + request>")\` to create detailed work breakdown
|
||||
4. Execute with continuous verification against original requirements
|
||||
| Condition | Action |
|
||||
|-----------|--------|
|
||||
| Task has 2+ steps | MUST call plan agent |
|
||||
| Task scope unclear | MUST call plan agent |
|
||||
| Implementation required | MUST call plan agent |
|
||||
| Architecture decision needed | MUST call plan agent |
|
||||
|
||||
\`\`\`
|
||||
delegate_task(subagent_type="plan", prompt="<gathered context + user request>")
|
||||
\`\`\`
|
||||
|
||||
**WHY PLAN AGENT IS MANDATORY:**
|
||||
- Plan agent analyzes dependencies and parallel execution opportunities
|
||||
- Plan agent outputs a **parallel task graph** with waves and dependencies
|
||||
- Plan agent provides structured TODO list with category + skills per task
|
||||
- YOU are an orchestrator, NOT an implementer
|
||||
|
||||
### SESSION CONTINUITY WITH PLAN AGENT (CRITICAL)
|
||||
|
||||
**Plan agent returns a session_id. USE IT for follow-up interactions.**
|
||||
|
||||
| Scenario | Action |
|
||||
|----------|--------|
|
||||
| Plan agent asks clarifying questions | \`delegate_task(session_id="{returned_session_id}", prompt="<your answer>")\` |
|
||||
| Need to refine the plan | \`delegate_task(session_id="{returned_session_id}", prompt="Please adjust: <feedback>")\` |
|
||||
| Plan needs more detail | \`delegate_task(session_id="{returned_session_id}", prompt="Add more detail to Task N")\` |
|
||||
|
||||
**WHY SESSION_ID IS CRITICAL:**
|
||||
- Plan agent retains FULL conversation context
|
||||
- No repeated exploration or context gathering
|
||||
- Saves 70%+ tokens on follow-ups
|
||||
- Maintains interview continuity until plan is finalized
|
||||
|
||||
\`\`\`
|
||||
// WRONG: Starting fresh loses all context
|
||||
delegate_task(subagent_type="plan", prompt="Here's more info...")
|
||||
|
||||
// CORRECT: Resume preserves everything
|
||||
delegate_task(session_id="ses_abc123", prompt="Here's my answer to your question: ...")
|
||||
\`\`\`
|
||||
|
||||
**FAILURE TO CALL PLAN AGENT = INCOMPLETE WORK.**
|
||||
|
||||
---
|
||||
|
||||
## AGENTS / **CATEGORY + SKILLS** UTILIZATION PRINCIPLES
|
||||
|
||||
**DEFAULT BEHAVIOR: DELEGATE. DO NOT WORK YOURSELF.**
|
||||
|
||||
| Task Type | Action | Why |
|
||||
|-----------|--------|-----|
|
||||
| Codebase exploration | delegate_task(subagent_type="explore", run_in_background=true) | Parallel, context-efficient |
|
||||
| Documentation lookup | delegate_task(subagent_type="librarian", run_in_background=true) | Specialized knowledge |
|
||||
| Planning | delegate_task(subagent_type="plan") | Parallel task graph + structured TODO list |
|
||||
| Architecture/Debugging | delegate_task(subagent_type="oracle") | High-IQ reasoning |
|
||||
| Implementation | delegate_task(category="...", load_skills=[...]) | Domain-optimized models |
|
||||
|
||||
**CATEGORY + SKILL DELEGATION:**
|
||||
\`\`\`
|
||||
// Frontend work
|
||||
delegate_task(category="visual-engineering", load_skills=["frontend-ui-ux"])
|
||||
|
||||
// Complex logic
|
||||
delegate_task(category="ultrabrain", load_skills=["typescript-programmer"])
|
||||
|
||||
// Quick fixes
|
||||
delegate_task(category="quick", load_skills=["git-master"])
|
||||
\`\`\`
|
||||
|
||||
**YOU SHOULD ONLY DO IT YOURSELF WHEN:**
|
||||
- Task is trivially simple (1-2 lines, obvious change)
|
||||
- You have ALL context already loaded
|
||||
- Delegation overhead exceeds task complexity
|
||||
|
||||
**OTHERWISE: DELEGATE. ALWAYS.**
|
||||
|
||||
---
|
||||
|
||||
## EXECUTION RULES (PARALLELIZATION MANDATORY)
|
||||
|
||||
| Rule | Implementation |
|
||||
|------|----------------|
|
||||
| **PARALLEL FIRST** | Fire ALL independent agents simultaneously via delegate_task(run_in_background=true) |
|
||||
| **NEVER SEQUENTIAL** | If tasks A and B are independent, launch BOTH at once |
|
||||
| **10+ CONCURRENT** | Use 10+ background agents if needed for comprehensive exploration |
|
||||
| **COLLECT LATER** | Launch agents -> continue work -> background_output when needed |
|
||||
|
||||
**ANTI-PATTERN (BLOCKING):**
|
||||
\`\`\`
|
||||
// WRONG: Sequential, slow
|
||||
result1 = delegate_task(..., run_in_background=false) // waits
|
||||
result2 = delegate_task(..., run_in_background=false) // waits again
|
||||
\`\`\`
|
||||
|
||||
**CORRECT PATTERN:**
|
||||
\`\`\`
|
||||
// RIGHT: Parallel, fast
|
||||
delegate_task(..., run_in_background=true) // task_id_1
|
||||
delegate_task(..., run_in_background=true) // task_id_2
|
||||
delegate_task(..., run_in_background=true) // task_id_3
|
||||
// Continue working, collect with background_output when needed
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## WORKFLOW (MANDATORY SEQUENCE)
|
||||
|
||||
1. **GATHER CONTEXT** (parallel background agents):
|
||||
\`\`\`
|
||||
delegate_task(subagent_type="explore", run_in_background=true, prompt="...")
|
||||
delegate_task(subagent_type="librarian", run_in_background=true, prompt="...")
|
||||
\`\`\`
|
||||
|
||||
2. **INVOKE PLAN AGENT** (MANDATORY for non-trivial tasks):
|
||||
\`\`\`
|
||||
result = delegate_task(subagent_type="plan", prompt="<context + request>")
|
||||
// STORE the session_id for follow-ups!
|
||||
plan_session_id = result.session_id
|
||||
\`\`\`
|
||||
|
||||
3. **ITERATE WITH PLAN AGENT** (if clarification needed):
|
||||
\`\`\`
|
||||
// Use session_id to continue the conversation
|
||||
delegate_task(session_id=plan_session_id, prompt="<answer to plan agent's question>")
|
||||
\`\`\`
|
||||
|
||||
4. **EXECUTE VIA DELEGATION** (category + skills from plan agent's output):
|
||||
\`\`\`
|
||||
delegate_task(category="...", load_skills=[...], prompt="<task from plan>")
|
||||
\`\`\`
|
||||
|
||||
5. **VERIFY** against original requirements
|
||||
|
||||
## VERIFICATION GUARANTEE (NON-NEGOTIABLE)
|
||||
|
||||
@@ -268,7 +450,8 @@ THE USER ASKED FOR X. DELIVER EXACTLY X. NOT A SUBSET. NOT A DEMO. NOT A STARTIN
|
||||
|
||||
1. EXPLORES + LIBRARIANS (background)
|
||||
2. GATHER -> delegate_task(subagent_type="plan", prompt="<context + request>")
|
||||
3. WORK BY DELEGATING TO CATEGORY + SKILLS AGENTS
|
||||
3. ITERATE WITH PLAN AGENT (session_id resume) UNTIL PLAN IS FINALIZED
|
||||
4. WORK BY DELEGATING TO CATEGORY + SKILLS AGENTS (following plan agent's parallel task graph)
|
||||
|
||||
NOW.
|
||||
|
||||
|
||||
@@ -338,6 +338,197 @@ describe("keyword-detector word boundary", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("keyword-detector system-reminder filtering", () => {
|
||||
let logCalls: Array<{ msg: string; data?: unknown }>
|
||||
let logSpy: ReturnType<typeof spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
setMainSession(undefined)
|
||||
logCalls = []
|
||||
logSpy = spyOn(sharedModule, "log").mockImplementation((msg: string, data?: unknown) => {
|
||||
logCalls.push({ msg, data })
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
logSpy?.mockRestore()
|
||||
setMainSession(undefined)
|
||||
})
|
||||
|
||||
function createMockPluginInput() {
|
||||
return {
|
||||
client: {
|
||||
tui: {
|
||||
showToast: async () => {},
|
||||
},
|
||||
},
|
||||
} as any
|
||||
}
|
||||
|
||||
test("should NOT trigger search mode from keywords inside <system-reminder> tags", async () => {
|
||||
// #given - message contains search keywords only inside system-reminder tags
|
||||
const collector = new ContextCollector()
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
|
||||
const sessionID = "test-session"
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{
|
||||
type: "text",
|
||||
text: `<system-reminder>
|
||||
The system will search for the file and find all occurrences.
|
||||
Please locate and scan the directory.
|
||||
</system-reminder>`
|
||||
}],
|
||||
}
|
||||
|
||||
// #when - keyword detection runs on system-reminder content
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// #then - should NOT trigger search mode (text should remain unchanged)
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).not.toContain("[search-mode]")
|
||||
expect(textPart!.text).toContain("<system-reminder>")
|
||||
})
|
||||
|
||||
test("should NOT trigger analyze mode from keywords inside <system-reminder> tags", async () => {
|
||||
// #given - message contains analyze keywords only inside system-reminder tags
|
||||
const collector = new ContextCollector()
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
|
||||
const sessionID = "test-session"
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{
|
||||
type: "text",
|
||||
text: `<system-reminder>
|
||||
You should investigate and examine the code carefully.
|
||||
Research the implementation details.
|
||||
</system-reminder>`
|
||||
}],
|
||||
}
|
||||
|
||||
// #when - keyword detection runs on system-reminder content
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// #then - should NOT trigger analyze mode
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).not.toContain("[analyze-mode]")
|
||||
expect(textPart!.text).toContain("<system-reminder>")
|
||||
})
|
||||
|
||||
test("should detect keywords in user text even when system-reminder is present", async () => {
|
||||
// #given - message contains both system-reminder and user search keyword
|
||||
const collector = new ContextCollector()
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
|
||||
const sessionID = "test-session"
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{
|
||||
type: "text",
|
||||
text: `<system-reminder>
|
||||
System will find and locate files.
|
||||
</system-reminder>
|
||||
|
||||
Please search for the bug in the code.`
|
||||
}],
|
||||
}
|
||||
|
||||
// #when - keyword detection runs on mixed content
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// #then - should trigger search mode from user text only
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toContain("[search-mode]")
|
||||
expect(textPart!.text).toContain("Please search for the bug in the code.")
|
||||
})
|
||||
|
||||
test("should handle multiple system-reminder tags in message", async () => {
|
||||
// #given - message contains multiple system-reminder blocks with keywords
|
||||
const collector = new ContextCollector()
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
|
||||
const sessionID = "test-session"
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{
|
||||
type: "text",
|
||||
text: `<system-reminder>
|
||||
First reminder with search and find keywords.
|
||||
</system-reminder>
|
||||
|
||||
User message without keywords.
|
||||
|
||||
<system-reminder>
|
||||
Second reminder with investigate and examine keywords.
|
||||
</system-reminder>`
|
||||
}],
|
||||
}
|
||||
|
||||
// #when - keyword detection runs on message with multiple system-reminders
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// #then - should NOT trigger any mode (only user text exists, no keywords)
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).not.toContain("[search-mode]")
|
||||
expect(textPart!.text).not.toContain("[analyze-mode]")
|
||||
})
|
||||
|
||||
test("should handle case-insensitive system-reminder tags", async () => {
|
||||
// #given - message contains system-reminder with different casing
|
||||
const collector = new ContextCollector()
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
|
||||
const sessionID = "test-session"
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{
|
||||
type: "text",
|
||||
text: `<SYSTEM-REMINDER>
|
||||
System will search and find files.
|
||||
</SYSTEM-REMINDER>`
|
||||
}],
|
||||
}
|
||||
|
||||
// #when - keyword detection runs on uppercase system-reminder
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// #then - should NOT trigger search mode
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).not.toContain("[search-mode]")
|
||||
})
|
||||
|
||||
test("should handle multiline system-reminder content with search keywords", async () => {
|
||||
// #given - system-reminder with multiline content containing various search keywords
|
||||
const collector = new ContextCollector()
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
|
||||
const sessionID = "test-session"
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{
|
||||
type: "text",
|
||||
text: `<system-reminder>
|
||||
Commands executed:
|
||||
- find: searched for pattern
|
||||
- grep: located file
|
||||
- scan: completed
|
||||
|
||||
Please explore the codebase and discover patterns.
|
||||
</system-reminder>`
|
||||
}],
|
||||
}
|
||||
|
||||
// #when - keyword detection runs on multiline system-reminder
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// #then - should NOT trigger search mode
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).not.toContain("[search-mode]")
|
||||
})
|
||||
})
|
||||
|
||||
describe("keyword-detector agent-specific ultrawork messages", () => {
|
||||
let logCalls: Array<{ msg: string; data?: unknown }>
|
||||
let logSpy: ReturnType<typeof spyOn>
|
||||
@@ -365,7 +556,7 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
|
||||
} as any
|
||||
}
|
||||
|
||||
test("should use planner-specific ultrawork message when agent is prometheus", async () => {
|
||||
test("should skip ultrawork injection when agent is prometheus", async () => {
|
||||
// #given - collector and prometheus agent
|
||||
const collector = new ContextCollector()
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
|
||||
@@ -378,16 +569,15 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
|
||||
// #when - ultrawork keyword detected with prometheus agent
|
||||
await hook["chat.message"]({ sessionID, agent: "prometheus" }, output)
|
||||
|
||||
// #then - should use planner-specific message with "YOU ARE A PLANNER" content
|
||||
// #then - ultrawork should be skipped for planner agents, text unchanged
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
|
||||
expect(textPart!.text).toBe("ultrawork plan this feature")
|
||||
expect(textPart!.text).not.toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
|
||||
expect(textPart!.text).not.toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS")
|
||||
expect(textPart!.text).toContain("---")
|
||||
expect(textPart!.text).toContain("plan this feature")
|
||||
})
|
||||
|
||||
test("should use planner-specific ultrawork message when agent name contains 'planner'", async () => {
|
||||
test("should skip ultrawork injection when agent name contains 'planner'", async () => {
|
||||
// #given - collector and agent with 'planner' in name
|
||||
const collector = new ContextCollector()
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
|
||||
@@ -400,12 +590,11 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
|
||||
// #when - ultrawork keyword detected with planner agent
|
||||
await hook["chat.message"]({ sessionID, agent: "Prometheus (Planner)" }, output)
|
||||
|
||||
// #then - should use planner-specific message
|
||||
// #then - ultrawork should be skipped, text unchanged
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
|
||||
expect(textPart!.text).toContain("---")
|
||||
expect(textPart!.text).toContain("create a work plan")
|
||||
expect(textPart!.text).toBe("ulw create a work plan")
|
||||
expect(textPart!.text).not.toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
|
||||
})
|
||||
|
||||
test("should use normal ultrawork message when agent is Sisyphus", async () => {
|
||||
@@ -452,7 +641,7 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
|
||||
expect(textPart!.text).toContain("do something")
|
||||
})
|
||||
|
||||
test("should switch from planner to normal message when agent changes", async () => {
|
||||
test("should skip ultrawork for prometheus but inject for sisyphus", async () => {
|
||||
// #given - two sessions, one with prometheus, one with sisyphus
|
||||
const collector = new ContextCollector()
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
|
||||
@@ -473,11 +662,9 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
|
||||
}
|
||||
await hook["chat.message"]({ sessionID: sisyphusSessionID, agent: "sisyphus" }, sisyphusOutput)
|
||||
|
||||
// #then - each session should have the correct message type
|
||||
// #then - prometheus should have no injection, sisyphus should have normal ultrawork
|
||||
const prometheusTextPart = prometheusOutput.parts.find(p => p.type === "text")
|
||||
expect(prometheusTextPart!.text).toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
|
||||
expect(prometheusTextPart!.text).toContain("---")
|
||||
expect(prometheusTextPart!.text).toContain("plan")
|
||||
expect(prometheusTextPart!.text).toBe("ultrawork plan")
|
||||
|
||||
const sisyphusTextPart = sisyphusOutput.parts.find(p => p.type === "text")
|
||||
expect(sisyphusTextPart!.text).toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS")
|
||||
@@ -514,7 +701,7 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
|
||||
clearSessionAgent(sessionID)
|
||||
})
|
||||
|
||||
test("should fall back to input.agent when session state is empty", async () => {
|
||||
test("should fall back to input.agent when session state is empty and skip ultrawork for prometheus", async () => {
|
||||
// #given - no session state, only input.agent available
|
||||
const collector = new ContextCollector()
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
|
||||
@@ -531,11 +718,10 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
|
||||
// #when - hook receives input.agent="prometheus" with no session state
|
||||
await hook["chat.message"]({ sessionID, agent: "prometheus" }, output)
|
||||
|
||||
// #then - should use prometheus from input.agent as fallback
|
||||
// #then - prometheus fallback from input.agent, ultrawork skipped
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
|
||||
expect(textPart!.text).toContain("---")
|
||||
expect(textPart!.text).toContain("plan this")
|
||||
expect(textPart!.text).toBe("ultrawork plan this")
|
||||
expect(textPart!.text).not.toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { detectKeywordsWithType, extractPromptText, removeCodeBlocks } from "./detector"
|
||||
import { isPlannerAgent } from "./constants"
|
||||
import { log } from "../../shared"
|
||||
import { isSystemDirective } from "../../shared/system-directive"
|
||||
import { hasSystemReminder, isSystemDirective, removeSystemReminders } from "../../shared/system-directive"
|
||||
import { getMainSessionID, getSessionAgent, subagentSessions } from "../../features/claude-code-session-state"
|
||||
import type { ContextCollector } from "../../features/context-injector"
|
||||
|
||||
@@ -31,7 +32,14 @@ export function createKeywordDetectorHook(ctx: PluginInput, collector?: ContextC
|
||||
}
|
||||
|
||||
const currentAgent = getSessionAgent(input.sessionID) ?? input.agent
|
||||
let detectedKeywords = detectKeywordsWithType(removeCodeBlocks(promptText), currentAgent)
|
||||
|
||||
// Remove system-reminder content to prevent automated system messages from triggering mode keywords
|
||||
const cleanText = removeSystemReminders(promptText)
|
||||
let detectedKeywords = detectKeywordsWithType(removeCodeBlocks(cleanText), currentAgent)
|
||||
|
||||
if (isPlannerAgent(currentAgent)) {
|
||||
detectedKeywords = detectedKeywords.filter((k) => k.type !== "ultrawork")
|
||||
}
|
||||
|
||||
if (detectedKeywords.length === 0) {
|
||||
return
|
||||
|
||||
@@ -891,40 +891,40 @@ Original task: Build something`
|
||||
})
|
||||
|
||||
describe("API timeout protection", () => {
|
||||
// FIXME: Flaky in CI - times out intermittently
|
||||
test.skip("should not hang when session.messages() times out", async () => {
|
||||
// #given - slow API that takes longer than timeout
|
||||
const slowMock = {
|
||||
test("should not hang when session.messages() throws", async () => {
|
||||
// #given - API that throws (simulates timeout error)
|
||||
let apiCallCount = 0
|
||||
const errorMock = {
|
||||
...createMockPluginInput(),
|
||||
client: {
|
||||
...createMockPluginInput().client,
|
||||
session: {
|
||||
...createMockPluginInput().client.session,
|
||||
messages: async () => {
|
||||
// Simulate slow API (would hang without timeout)
|
||||
await new Promise((resolve) => setTimeout(resolve, 10000))
|
||||
return { data: [] }
|
||||
apiCallCount++
|
||||
throw new Error("API timeout")
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
const hook = createRalphLoopHook(slowMock as any, {
|
||||
const hook = createRalphLoopHook(errorMock as any, {
|
||||
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
|
||||
apiTimeout: 100, // 100ms timeout for test
|
||||
apiTimeout: 100,
|
||||
})
|
||||
hook.startLoop("session-123", "Build something")
|
||||
|
||||
// #when - session goes idle (API will timeout)
|
||||
// #when - session goes idle (API will throw)
|
||||
const startTime = Date.now()
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
const elapsed = Date.now() - startTime
|
||||
|
||||
// #then - should complete within timeout + buffer (not hang for 10s)
|
||||
expect(elapsed).toBeLessThan(500)
|
||||
// #then - loop should continue (API timeout = no completion detected)
|
||||
// #then - should complete quickly (not hang for 10s)
|
||||
expect(elapsed).toBeLessThan(2000)
|
||||
// #then - loop should continue (API error = no completion detected)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
expect(apiCallCount).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
82
src/hooks/subagent-question-blocker/index.test.ts
Normal file
82
src/hooks/subagent-question-blocker/index.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, test, expect, beforeEach } from "bun:test"
|
||||
import { createSubagentQuestionBlockerHook } from "./index"
|
||||
import { subagentSessions, _resetForTesting } from "../../features/claude-code-session-state"
|
||||
|
||||
describe("createSubagentQuestionBlockerHook", () => {
|
||||
const hook = createSubagentQuestionBlockerHook()
|
||||
|
||||
beforeEach(() => {
|
||||
_resetForTesting()
|
||||
})
|
||||
|
||||
describe("tool.execute.before", () => {
|
||||
test("allows question tool for non-subagent sessions", async () => {
|
||||
//#given
|
||||
const sessionID = "ses_main"
|
||||
const input = { tool: "question", sessionID, callID: "call_1" }
|
||||
const output = { args: { questions: [] } }
|
||||
|
||||
//#when
|
||||
const result = hook["tool.execute.before"]?.(input as any, output as any)
|
||||
|
||||
//#then
|
||||
await expect(result).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("blocks question tool for subagent sessions", async () => {
|
||||
//#given
|
||||
const sessionID = "ses_subagent"
|
||||
subagentSessions.add(sessionID)
|
||||
const input = { tool: "question", sessionID, callID: "call_1" }
|
||||
const output = { args: { questions: [] } }
|
||||
|
||||
//#when
|
||||
const result = hook["tool.execute.before"]?.(input as any, output as any)
|
||||
|
||||
//#then
|
||||
await expect(result).rejects.toThrow("Question tool is disabled for subagent sessions")
|
||||
})
|
||||
|
||||
test("blocks Question tool (case insensitive) for subagent sessions", async () => {
|
||||
//#given
|
||||
const sessionID = "ses_subagent"
|
||||
subagentSessions.add(sessionID)
|
||||
const input = { tool: "Question", sessionID, callID: "call_1" }
|
||||
const output = { args: { questions: [] } }
|
||||
|
||||
//#when
|
||||
const result = hook["tool.execute.before"]?.(input as any, output as any)
|
||||
|
||||
//#then
|
||||
await expect(result).rejects.toThrow("Question tool is disabled for subagent sessions")
|
||||
})
|
||||
|
||||
test("blocks AskUserQuestion tool for subagent sessions", async () => {
|
||||
//#given
|
||||
const sessionID = "ses_subagent"
|
||||
subagentSessions.add(sessionID)
|
||||
const input = { tool: "AskUserQuestion", sessionID, callID: "call_1" }
|
||||
const output = { args: { questions: [] } }
|
||||
|
||||
//#when
|
||||
const result = hook["tool.execute.before"]?.(input as any, output as any)
|
||||
|
||||
//#then
|
||||
await expect(result).rejects.toThrow("Question tool is disabled for subagent sessions")
|
||||
})
|
||||
|
||||
test("ignores non-question tools for subagent sessions", async () => {
|
||||
//#given
|
||||
const sessionID = "ses_subagent"
|
||||
subagentSessions.add(sessionID)
|
||||
const input = { tool: "bash", sessionID, callID: "call_1" }
|
||||
const output = { args: { command: "ls" } }
|
||||
|
||||
//#when
|
||||
const result = hook["tool.execute.before"]?.(input as any, output as any)
|
||||
|
||||
//#then
|
||||
await expect(result).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
29
src/hooks/subagent-question-blocker/index.ts
Normal file
29
src/hooks/subagent-question-blocker/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Hooks } from "@opencode-ai/plugin"
|
||||
import { subagentSessions } from "../../features/claude-code-session-state"
|
||||
import { log } from "../../shared"
|
||||
|
||||
export function createSubagentQuestionBlockerHook(): Hooks {
|
||||
return {
|
||||
"tool.execute.before": async (input) => {
|
||||
const toolName = input.tool?.toLowerCase()
|
||||
if (toolName !== "question" && toolName !== "askuserquestion") {
|
||||
return
|
||||
}
|
||||
|
||||
if (!subagentSessions.has(input.sessionID)) {
|
||||
return
|
||||
}
|
||||
|
||||
log("[subagent-question-blocker] Blocking question tool call from subagent session", {
|
||||
sessionID: input.sessionID,
|
||||
tool: input.tool,
|
||||
})
|
||||
|
||||
throw new Error(
|
||||
"Question tool is disabled for subagent sessions. " +
|
||||
"Subagents should complete their work autonomously without asking questions to users. " +
|
||||
"If you need clarification, return to the parent agent with your findings and uncertainties."
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -350,4 +350,63 @@ describe("createThinkModeHook integration", () => {
|
||||
expect(input.message.model?.modelID).toBe("claude-opus-4-5")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Agent-level thinking configuration respect", () => {
|
||||
it("should NOT inject thinking config when agent has thinking disabled", async () => {
|
||||
// #given agent with thinking explicitly disabled
|
||||
const hook = createThinkModeHook()
|
||||
const input: ThinkModeInput = {
|
||||
parts: [{ type: "text", text: "ultrathink deeply" }],
|
||||
message: {
|
||||
model: { providerID: "google", modelID: "gemini-3-pro" },
|
||||
thinking: { type: "disabled" },
|
||||
} as ThinkModeInput["message"],
|
||||
}
|
||||
|
||||
// #when the chat.params hook is called
|
||||
await hook["chat.params"](input, sessionID)
|
||||
|
||||
// #then should NOT override agent's thinking disabled setting
|
||||
const message = input.message as MessageWithInjectedProps
|
||||
expect((message.thinking as { type: string }).type).toBe("disabled")
|
||||
expect(message.providerOptions).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should NOT inject thinking config when agent has custom providerOptions", async () => {
|
||||
// #given agent with custom providerOptions
|
||||
const hook = createThinkModeHook()
|
||||
const input: ThinkModeInput = {
|
||||
parts: [{ type: "text", text: "ultrathink" }],
|
||||
message: {
|
||||
model: { providerID: "google", modelID: "gemini-3-flash" },
|
||||
providerOptions: {
|
||||
google: { thinkingConfig: { thinkingBudget: 0 } },
|
||||
},
|
||||
} as ThinkModeInput["message"],
|
||||
}
|
||||
|
||||
// #when the chat.params hook is called
|
||||
await hook["chat.params"](input, sessionID)
|
||||
|
||||
// #then should NOT override agent's providerOptions
|
||||
const message = input.message as MessageWithInjectedProps
|
||||
const providerOpts = message.providerOptions as Record<string, unknown>
|
||||
expect((providerOpts.google as Record<string, unknown>).thinkingConfig).toEqual({
|
||||
thinkingBudget: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it("should still inject thinking config when agent has no thinking override", async () => {
|
||||
// #given agent without thinking override
|
||||
const hook = createThinkModeHook()
|
||||
const input = createMockInput("google", "gemini-3-pro", "ultrathink")
|
||||
|
||||
// #when the chat.params hook is called
|
||||
await hook["chat.params"](input, sessionID)
|
||||
|
||||
// #then should inject thinking config as normal
|
||||
const message = input.message as MessageWithInjectedProps
|
||||
expect(message.providerOptions).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -65,13 +65,32 @@ export function createThinkModeHook() {
|
||||
}
|
||||
|
||||
if (thinkingConfig) {
|
||||
Object.assign(output.message, thinkingConfig)
|
||||
state.thinkingConfigInjected = true
|
||||
log("Think mode: thinking config injected", {
|
||||
sessionID,
|
||||
provider: currentModel.providerID,
|
||||
config: thinkingConfig,
|
||||
})
|
||||
const messageData = output.message as Record<string, unknown>
|
||||
const agentThinking = messageData.thinking as { type?: string } | undefined
|
||||
const agentProviderOptions = messageData.providerOptions
|
||||
|
||||
const agentDisabledThinking = agentThinking?.type === "disabled"
|
||||
const agentHasCustomProviderOptions = Boolean(agentProviderOptions)
|
||||
|
||||
if (agentDisabledThinking) {
|
||||
log("Think mode: skipping - agent has thinking disabled", {
|
||||
sessionID,
|
||||
provider: currentModel.providerID,
|
||||
})
|
||||
} else if (agentHasCustomProviderOptions) {
|
||||
log("Think mode: skipping - agent has custom providerOptions", {
|
||||
sessionID,
|
||||
provider: currentModel.providerID,
|
||||
})
|
||||
} else {
|
||||
Object.assign(output.message, thinkingConfig)
|
||||
state.thinkingConfigInjected = true
|
||||
log("Think mode: thinking config injected", {
|
||||
sessionID,
|
||||
provider: currentModel.providerID,
|
||||
config: thinkingConfig,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
thinkModeState.set(sessionID, state)
|
||||
|
||||
58
src/index.ts
58
src/index.ts
@@ -34,12 +34,13 @@ import {
|
||||
createPrometheusMdOnlyHook,
|
||||
createSisyphusJuniorNotepadHook,
|
||||
createQuestionLabelTruncatorHook,
|
||||
createSubagentQuestionBlockerHook,
|
||||
} from "./hooks";
|
||||
import {
|
||||
contextCollector,
|
||||
createContextInjectorMessagesTransformHook,
|
||||
} from "./features/context-injector";
|
||||
import { applyAgentVariant, resolveAgentVariant } from "./shared/agent-variant";
|
||||
import { applyAgentVariant, resolveAgentVariant, resolveVariantForModel } from "./shared/agent-variant";
|
||||
import { createFirstMessageVariantGate } from "./shared/first-message-variant";
|
||||
import {
|
||||
discoverUserClaudeSkills,
|
||||
@@ -77,7 +78,7 @@ import { SkillMcpManager } from "./features/skill-mcp-manager";
|
||||
import { initTaskToastManager } from "./features/task-toast-manager";
|
||||
import { TmuxSessionManager } from "./features/tmux-subagent";
|
||||
import { type HookName } from "./config";
|
||||
import { log, detectExternalNotificationPlugin, getNotificationConflictWarning, resetMessageCursor, includesCaseInsensitive } from "./shared";
|
||||
import { log, detectExternalNotificationPlugin, getNotificationConflictWarning, resetMessageCursor, includesCaseInsensitive, hasConnectedProvidersCache, getOpenCodeVersion, isOpenCodeVersionAtLeast, OPENCODE_NATIVE_AGENTS_INJECTION_VERSION } from "./shared";
|
||||
import { loadPluginConfig } from "./plugin-config";
|
||||
import { createModelCacheState, getModelLimit } from "./plugin-state";
|
||||
import { createConfigHandler } from "./plugin-handlers";
|
||||
@@ -135,9 +136,26 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
experimental: pluginConfig.experimental,
|
||||
})
|
||||
: null;
|
||||
const directoryAgentsInjector = isHookEnabled("directory-agents-injector")
|
||||
? createDirectoryAgentsInjectorHook(ctx)
|
||||
: null;
|
||||
// Check for native OpenCode AGENTS.md injection support before creating hook
|
||||
let directoryAgentsInjector = null;
|
||||
if (isHookEnabled("directory-agents-injector")) {
|
||||
const currentVersion = getOpenCodeVersion();
|
||||
const hasNativeSupport = currentVersion !== null &&
|
||||
isOpenCodeVersionAtLeast(OPENCODE_NATIVE_AGENTS_INJECTION_VERSION);
|
||||
|
||||
if (hasNativeSupport) {
|
||||
console.warn(
|
||||
`[oh-my-opencode] directory-agents-injector hook auto-disabled: ` +
|
||||
`OpenCode ${currentVersion} has native AGENTS.md support (>= ${OPENCODE_NATIVE_AGENTS_INJECTION_VERSION})`
|
||||
);
|
||||
log("directory-agents-injector auto-disabled due to native OpenCode support", {
|
||||
currentVersion,
|
||||
nativeVersion: OPENCODE_NATIVE_AGENTS_INJECTION_VERSION,
|
||||
});
|
||||
} else {
|
||||
directoryAgentsInjector = createDirectoryAgentsInjectorHook(ctx);
|
||||
}
|
||||
}
|
||||
const directoryReadmeInjector = isHookEnabled("directory-readme-injector")
|
||||
? createDirectoryReadmeInjectorHook(ctx)
|
||||
: null;
|
||||
@@ -224,6 +242,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
: null;
|
||||
|
||||
const questionLabelTruncator = createQuestionLabelTruncatorHook();
|
||||
const subagentQuestionBlocker = createSubagentQuestionBlockerHook();
|
||||
|
||||
const taskResumeInfo = createTaskResumeInfoHook();
|
||||
|
||||
@@ -382,13 +401,22 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
|
||||
const message = (output as { message: { variant?: string } }).message
|
||||
if (firstMessageVariantGate.shouldOverride(input.sessionID)) {
|
||||
const variant = resolveAgentVariant(pluginConfig, input.agent)
|
||||
const variant = input.model && input.agent
|
||||
? resolveVariantForModel(pluginConfig, input.agent, input.model)
|
||||
: resolveAgentVariant(pluginConfig, input.agent)
|
||||
if (variant !== undefined) {
|
||||
message.variant = variant
|
||||
}
|
||||
firstMessageVariantGate.markApplied(input.sessionID)
|
||||
} else {
|
||||
applyAgentVariant(pluginConfig, input.agent, message)
|
||||
if (input.model && input.agent && message.variant === undefined) {
|
||||
const variant = resolveVariantForModel(pluginConfig, input.agent, input.model)
|
||||
if (variant !== undefined) {
|
||||
message.variant = variant
|
||||
}
|
||||
} else {
|
||||
applyAgentVariant(pluginConfig, input.agent, message)
|
||||
}
|
||||
}
|
||||
|
||||
await keywordDetector?.["chat.message"]?.(input, output);
|
||||
@@ -396,6 +424,17 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
await autoSlashCommand?.["chat.message"]?.(input, output);
|
||||
await startWork?.["chat.message"]?.(input, output);
|
||||
|
||||
if (!hasConnectedProvidersCache()) {
|
||||
ctx.client.tui.showToast({
|
||||
body: {
|
||||
title: "⚠️ Provider Cache Missing",
|
||||
message: "Model filtering disabled. RESTART OpenCode to enable full functionality.",
|
||||
variant: "warning" as const,
|
||||
duration: 6000,
|
||||
},
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
if (ralphLoop) {
|
||||
const parts = (
|
||||
output as { parts?: Array<{ type: string; text?: string }> }
|
||||
@@ -555,6 +594,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
},
|
||||
|
||||
"tool.execute.before": async (input, output) => {
|
||||
await subagentQuestionBlocker["tool.execute.before"]?.(input, output);
|
||||
await questionLabelTruncator["tool.execute.before"]?.(input, output);
|
||||
await claudeCodeHooks["tool.execute.before"](input, output);
|
||||
await nonInteractiveEnv?.["tool.execute.before"](input, output);
|
||||
@@ -634,6 +674,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
},
|
||||
|
||||
"tool.execute.after": async (input, output) => {
|
||||
// Guard against undefined output (e.g., from /review command - see issue #1035)
|
||||
if (!output) {
|
||||
return;
|
||||
}
|
||||
await claudeCodeHooks["tool.execute.after"](input, output);
|
||||
await toolOutputTruncator?.["tool.execute.after"](input, output);
|
||||
await contextWindowMonitor?.["tool.execute.after"](input, output);
|
||||
|
||||
@@ -21,7 +21,7 @@ mcp/
|
||||
| Name | URL | Purpose | Auth |
|
||||
|------|-----|---------|------|
|
||||
| websearch | mcp.exa.ai/mcp?tools=web_search_exa | Real-time web search | EXA_API_KEY |
|
||||
| context7 | mcp.context7.com/mcp | Library docs | None |
|
||||
| context7 | mcp.context7.com/mcp | Library docs | CONTEXT7_API_KEY |
|
||||
| grep_app | mcp.grep.app | GitHub code search | None |
|
||||
|
||||
## THREE-TIER MCP SYSTEM
|
||||
@@ -61,4 +61,5 @@ const mcps = createBuiltinMcps(["websearch"]) // Disable specific
|
||||
|
||||
- **Remote only**: HTTP/SSE, no stdio
|
||||
- **Disable**: User can set `disabled_mcps: ["name"]` in config
|
||||
- **Exa**: Requires `EXA_API_KEY` env var
|
||||
- **Context7**: Optional auth using `CONTEXT7_API_KEY` env var
|
||||
- **Exa**: Optional auth using `EXA_API_KEY` env var
|
||||
|
||||
@@ -2,5 +2,9 @@ export const context7 = {
|
||||
type: "remote" as const,
|
||||
url: "https://mcp.context7.com/mcp",
|
||||
enabled: true,
|
||||
headers: process.env.CONTEXT7_API_KEY
|
||||
? { Authorization: `Bearer ${process.env.CONTEXT7_API_KEY}` }
|
||||
: undefined,
|
||||
// Disable OAuth auto-detection - Context7 uses API key header, not OAuth
|
||||
oauth: false as const,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,185 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { resolveCategoryConfig } from "./config-handler"
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test"
|
||||
import { resolveCategoryConfig, createConfigHandler } from "./config-handler"
|
||||
import type { CategoryConfig } from "../config/schema"
|
||||
import type { OhMyOpenCodeConfig } from "../config"
|
||||
|
||||
mock.module("../agents", () => ({
|
||||
createBuiltinAgents: async () => ({
|
||||
sisyphus: { name: "sisyphus", prompt: "test", mode: "primary" },
|
||||
oracle: { name: "oracle", prompt: "test", mode: "subagent" },
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("../agents/sisyphus-junior", () => ({
|
||||
createSisyphusJuniorAgentWithOverrides: () => ({
|
||||
name: "sisyphus-junior",
|
||||
prompt: "test",
|
||||
mode: "subagent",
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("../features/claude-code-command-loader", () => ({
|
||||
loadUserCommands: async () => ({}),
|
||||
loadProjectCommands: async () => ({}),
|
||||
loadOpencodeGlobalCommands: async () => ({}),
|
||||
loadOpencodeProjectCommands: async () => ({}),
|
||||
}))
|
||||
|
||||
mock.module("../features/builtin-commands", () => ({
|
||||
loadBuiltinCommands: () => ({}),
|
||||
}))
|
||||
|
||||
mock.module("../features/opencode-skill-loader", () => ({
|
||||
loadUserSkills: async () => ({}),
|
||||
loadProjectSkills: async () => ({}),
|
||||
loadOpencodeGlobalSkills: async () => ({}),
|
||||
loadOpencodeProjectSkills: async () => ({}),
|
||||
discoverUserClaudeSkills: async () => [],
|
||||
discoverProjectClaudeSkills: async () => [],
|
||||
discoverOpencodeGlobalSkills: async () => [],
|
||||
discoverOpencodeProjectSkills: async () => [],
|
||||
}))
|
||||
|
||||
mock.module("../features/claude-code-agent-loader", () => ({
|
||||
loadUserAgents: () => ({}),
|
||||
loadProjectAgents: () => ({}),
|
||||
}))
|
||||
|
||||
mock.module("../features/claude-code-mcp-loader", () => ({
|
||||
loadMcpConfigs: async () => ({ servers: {} }),
|
||||
}))
|
||||
|
||||
mock.module("../features/claude-code-plugin-loader", () => ({
|
||||
loadAllPluginComponents: async () => ({
|
||||
commands: {},
|
||||
skills: {},
|
||||
agents: {},
|
||||
mcpServers: {},
|
||||
hooksConfigs: [],
|
||||
plugins: [],
|
||||
errors: [],
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("../mcp", () => ({
|
||||
createBuiltinMcps: () => ({}),
|
||||
}))
|
||||
|
||||
mock.module("../shared", () => ({
|
||||
log: () => {},
|
||||
fetchAvailableModels: async () => new Set(["anthropic/claude-opus-4-5"]),
|
||||
readConnectedProvidersCache: () => null,
|
||||
}))
|
||||
|
||||
mock.module("../shared/opencode-config-dir", () => ({
|
||||
getOpenCodeConfigPaths: () => ({
|
||||
global: "/tmp/.config/opencode",
|
||||
project: "/tmp/.opencode",
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("../shared/permission-compat", () => ({
|
||||
migrateAgentConfig: (config: Record<string, unknown>) => config,
|
||||
}))
|
||||
|
||||
mock.module("../shared/migration", () => ({
|
||||
AGENT_NAME_MAP: {},
|
||||
}))
|
||||
|
||||
mock.module("../shared/model-resolver", () => ({
|
||||
resolveModelWithFallback: () => ({ model: "anthropic/claude-opus-4-5" }),
|
||||
}))
|
||||
|
||||
mock.module("../shared/model-requirements", () => ({
|
||||
AGENT_MODEL_REQUIREMENTS: {
|
||||
sisyphus: { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5" }] },
|
||||
oracle: { fallbackChain: [{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" }] },
|
||||
librarian: { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" }] },
|
||||
explore: { fallbackChain: [{ providers: ["anthropic", "opencode"], model: "claude-haiku-4-5" }] },
|
||||
"multimodal-looker": { fallbackChain: [{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" }] },
|
||||
prometheus: { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5" }] },
|
||||
metis: { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5" }] },
|
||||
momus: { fallbackChain: [{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" }] },
|
||||
atlas: { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" }] },
|
||||
},
|
||||
CATEGORY_MODEL_REQUIREMENTS: {
|
||||
"visual-engineering": { fallbackChain: [{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" }] },
|
||||
ultrabrain: { fallbackChain: [{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2-codex" }] },
|
||||
artistry: { fallbackChain: [{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" }] },
|
||||
quick: { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-haiku-4-5" }] },
|
||||
"unspecified-low": { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" }] },
|
||||
"unspecified-high": { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5" }] },
|
||||
writing: { fallbackChain: [{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" }] },
|
||||
},
|
||||
}))
|
||||
|
||||
describe("Plan agent demote behavior", () => {
|
||||
test("plan agent should be demoted to subagent mode when replacePlan is true", async () => {
|
||||
// #given
|
||||
const pluginConfig: OhMyOpenCodeConfig = {
|
||||
sisyphus_agent: {
|
||||
planner_enabled: true,
|
||||
replace_plan: true,
|
||||
},
|
||||
}
|
||||
const config: Record<string, unknown> = {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
agent: {
|
||||
plan: {
|
||||
name: "plan",
|
||||
mode: "primary",
|
||||
prompt: "original plan prompt",
|
||||
},
|
||||
},
|
||||
}
|
||||
const handler = createConfigHandler({
|
||||
ctx: { directory: "/tmp" },
|
||||
pluginConfig,
|
||||
modelCacheState: {
|
||||
anthropicContext1MEnabled: false,
|
||||
modelContextLimitsCache: new Map(),
|
||||
},
|
||||
})
|
||||
|
||||
// #when
|
||||
await handler(config)
|
||||
|
||||
// #then
|
||||
const agents = config.agent as Record<string, { mode?: string; name?: string }>
|
||||
expect(agents.plan).toBeDefined()
|
||||
expect(agents.plan.mode).toBe("subagent")
|
||||
expect(agents.plan.name).toBe("plan")
|
||||
})
|
||||
|
||||
test("prometheus should have mode 'all' to be callable via delegate_task", async () => {
|
||||
// #given
|
||||
const pluginConfig: OhMyOpenCodeConfig = {
|
||||
sisyphus_agent: {
|
||||
planner_enabled: true,
|
||||
},
|
||||
}
|
||||
const config: Record<string, unknown> = {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
agent: {},
|
||||
}
|
||||
const handler = createConfigHandler({
|
||||
ctx: { directory: "/tmp" },
|
||||
pluginConfig,
|
||||
modelCacheState: {
|
||||
anthropicContext1MEnabled: false,
|
||||
modelContextLimitsCache: new Map(),
|
||||
},
|
||||
})
|
||||
|
||||
// #when
|
||||
await handler(config)
|
||||
|
||||
// #then
|
||||
const agents = config.agent as Record<string, { mode?: string }>
|
||||
expect(agents.prometheus).toBeDefined()
|
||||
expect(agents.prometheus.mode).toBe("all")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Prometheus category config resolution", () => {
|
||||
test("resolves ultrabrain category config", () => {
|
||||
|
||||
@@ -25,10 +25,12 @@ import { loadMcpConfigs } from "../features/claude-code-mcp-loader";
|
||||
import { loadAllPluginComponents } from "../features/claude-code-plugin-loader";
|
||||
import { createBuiltinMcps } from "../mcp";
|
||||
import type { OhMyOpenCodeConfig } from "../config";
|
||||
import { log } from "../shared";
|
||||
import { log, fetchAvailableModels, readConnectedProvidersCache } from "../shared";
|
||||
import { getOpenCodeConfigPaths } from "../shared/opencode-config-dir";
|
||||
import { migrateAgentConfig } from "../shared/permission-compat";
|
||||
import { AGENT_NAME_MAP } from "../shared/migration";
|
||||
import { resolveModelWithFallback } from "../shared/model-resolver";
|
||||
import { AGENT_MODEL_REQUIREMENTS } from "../shared/model-requirements";
|
||||
import { PROMETHEUS_SYSTEM_PROMPT, PROMETHEUS_PERMISSION } from "../agents/prometheus-prompt";
|
||||
import { DEFAULT_CATEGORIES } from "../tools/delegate-task/constants";
|
||||
import type { ModelCacheState } from "../plugin-state";
|
||||
@@ -105,41 +107,6 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
log(`Plugin load errors`, { errors: pluginComponents.errors });
|
||||
}
|
||||
|
||||
if (!(config.model as string | undefined)?.trim()) {
|
||||
let fallbackModel: string | undefined
|
||||
|
||||
for (const agentConfig of Object.values(pluginConfig.agents ?? {})) {
|
||||
const model = (agentConfig as { model?: string })?.model
|
||||
if (model && typeof model === 'string' && model.trim()) {
|
||||
fallbackModel = model.trim()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!fallbackModel) {
|
||||
for (const categoryConfig of Object.values(pluginConfig.categories ?? {})) {
|
||||
const model = (categoryConfig as { model?: string })?.model
|
||||
if (model && typeof model === 'string' && model.trim()) {
|
||||
fallbackModel = model.trim()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackModel) {
|
||||
config.model = fallbackModel
|
||||
log(`No default model specified, using fallback from config: ${fallbackModel}`)
|
||||
} else {
|
||||
const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null })
|
||||
throw new Error(
|
||||
'oh-my-opencode requires a default model.\n\n' +
|
||||
`Add this to ${paths.configJsonc}:\n\n` +
|
||||
' "model": "anthropic/claude-sonnet-4-5"\n\n' +
|
||||
'(Replace with your preferred provider/model)'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate disabled_agents from old names to new names
|
||||
const migratedDisabledAgents = (pluginConfig.disabled_agents ?? []).map(agent => {
|
||||
return AGENT_NAME_MAP[agent.toLowerCase()] ?? AGENT_NAME_MAP[agent] ?? agent
|
||||
@@ -256,13 +223,10 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
);
|
||||
const prometheusOverride =
|
||||
pluginConfig.agents?.["prometheus"] as
|
||||
| (Record<string, unknown> & { category?: string; model?: string })
|
||||
| (Record<string, unknown> & { category?: string; model?: string; variant?: string })
|
||||
| undefined;
|
||||
const defaultModel = config.model as string | undefined;
|
||||
|
||||
// Resolve full category config (model, temperature, top_p, tools, etc.)
|
||||
// Apply all category properties when category is specified, but explicit
|
||||
// overrides (model, temperature, etc.) will take precedence during merge
|
||||
const categoryConfig = prometheusOverride?.category
|
||||
? resolveCategoryConfig(
|
||||
prometheusOverride.category,
|
||||
@@ -270,19 +234,31 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// Model resolution: explicit override → category config → OpenCode default
|
||||
// No hardcoded fallback - OpenCode config.model is the terminal fallback
|
||||
const resolvedModel = prometheusOverride?.model ?? categoryConfig?.model ?? defaultModel;
|
||||
const prometheusRequirement = AGENT_MODEL_REQUIREMENTS["prometheus"];
|
||||
const connectedProviders = readConnectedProvidersCache();
|
||||
const availableModels = ctx.client
|
||||
? await fetchAvailableModels(ctx.client, { connectedProviders: connectedProviders ?? undefined })
|
||||
: new Set<string>();
|
||||
|
||||
const modelResolution = resolveModelWithFallback({
|
||||
userModel: prometheusOverride?.model ?? categoryConfig?.model,
|
||||
fallbackChain: prometheusRequirement?.fallbackChain,
|
||||
availableModels,
|
||||
systemDefaultModel: defaultModel ?? "",
|
||||
});
|
||||
const resolvedModel = modelResolution?.model;
|
||||
const resolvedVariant = modelResolution?.variant;
|
||||
|
||||
const variantToUse = prometheusOverride?.variant ?? resolvedVariant;
|
||||
const prometheusBase = {
|
||||
// Only include model if one was resolved - let OpenCode apply its own default if none
|
||||
name: "prometheus",
|
||||
...(resolvedModel ? { model: resolvedModel } : {}),
|
||||
mode: "primary" as const,
|
||||
...(variantToUse ? { variant: variantToUse } : {}),
|
||||
mode: "all" as const,
|
||||
prompt: PROMETHEUS_SYSTEM_PROMPT,
|
||||
permission: PROMETHEUS_PERMISSION,
|
||||
description: `${configAgent?.plan?.description ?? "Plan agent"} (Prometheus - OhMyOpenCode)`,
|
||||
color: (configAgent?.plan?.color as string) ?? "#FF6347",
|
||||
// Apply category properties (temperature, top_p, tools, etc.)
|
||||
...(categoryConfig?.temperature !== undefined
|
||||
? { temperature: categoryConfig.temperature }
|
||||
: {}),
|
||||
@@ -330,8 +306,12 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
? migrateAgentConfig(configAgent.build as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const planDemoteConfig = replacePlan
|
||||
? { mode: "subagent" as const }
|
||||
const planDemoteConfig = replacePlan && agentConfig["prometheus"]
|
||||
? {
|
||||
...agentConfig["prometheus"],
|
||||
name: "plan",
|
||||
mode: "subagent" as const
|
||||
}
|
||||
: undefined;
|
||||
|
||||
config.agent = {
|
||||
@@ -405,8 +385,8 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
: { servers: {} };
|
||||
|
||||
config.mcp = {
|
||||
...(config.mcp as Record<string, unknown>),
|
||||
...createBuiltinMcps(pluginConfig.disabled_mcps),
|
||||
...(config.mcp as Record<string, unknown>),
|
||||
...mcpResult.servers,
|
||||
...pluginComponents.mcpServers,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OhMyOpenCodeConfig } from "../config"
|
||||
import { applyAgentVariant, resolveAgentVariant } from "./agent-variant"
|
||||
import { applyAgentVariant, resolveAgentVariant, resolveVariantForModel } from "./agent-variant"
|
||||
|
||||
describe("resolveAgentVariant", () => {
|
||||
test("returns undefined when agent name missing", () => {
|
||||
@@ -81,3 +81,117 @@ describe("applyAgentVariant", () => {
|
||||
expect(message.variant).toBe("max")
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveVariantForModel", () => {
|
||||
test("returns correct variant for anthropic provider", () => {
|
||||
// #given
|
||||
const config = {} as OhMyOpenCodeConfig
|
||||
const model = { providerID: "anthropic", modelID: "claude-opus-4-5" }
|
||||
|
||||
// #when
|
||||
const variant = resolveVariantForModel(config, "sisyphus", model)
|
||||
|
||||
// #then
|
||||
expect(variant).toBe("max")
|
||||
})
|
||||
|
||||
test("returns correct variant for openai provider", () => {
|
||||
// #given
|
||||
const config = {} as OhMyOpenCodeConfig
|
||||
const model = { providerID: "openai", modelID: "gpt-5.2" }
|
||||
|
||||
// #when
|
||||
const variant = resolveVariantForModel(config, "sisyphus", model)
|
||||
|
||||
// #then
|
||||
expect(variant).toBe("medium")
|
||||
})
|
||||
|
||||
test("returns undefined for provider with no variant in chain", () => {
|
||||
// #given
|
||||
const config = {} as OhMyOpenCodeConfig
|
||||
const model = { providerID: "google", modelID: "gemini-3-pro" }
|
||||
|
||||
// #when
|
||||
const variant = resolveVariantForModel(config, "sisyphus", model)
|
||||
|
||||
// #then
|
||||
expect(variant).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns undefined for provider not in chain", () => {
|
||||
// #given
|
||||
const config = {} as OhMyOpenCodeConfig
|
||||
const model = { providerID: "unknown-provider", modelID: "some-model" }
|
||||
|
||||
// #when
|
||||
const variant = resolveVariantForModel(config, "sisyphus", model)
|
||||
|
||||
// #then
|
||||
expect(variant).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns undefined for unknown agent", () => {
|
||||
// #given
|
||||
const config = {} as OhMyOpenCodeConfig
|
||||
const model = { providerID: "anthropic", modelID: "claude-opus-4-5" }
|
||||
|
||||
// #when
|
||||
const variant = resolveVariantForModel(config, "nonexistent-agent", model)
|
||||
|
||||
// #then
|
||||
expect(variant).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns variant for zai-coding-plan provider without variant", () => {
|
||||
// #given
|
||||
const config = {} as OhMyOpenCodeConfig
|
||||
const model = { providerID: "zai-coding-plan", modelID: "glm-4.7" }
|
||||
|
||||
// #when
|
||||
const variant = resolveVariantForModel(config, "sisyphus", model)
|
||||
|
||||
// #then
|
||||
expect(variant).toBeUndefined()
|
||||
})
|
||||
|
||||
test("falls back to category chain when agent has no requirement", () => {
|
||||
// #given
|
||||
const config = {
|
||||
agents: {
|
||||
"custom-agent": { category: "ultrabrain" },
|
||||
},
|
||||
} as OhMyOpenCodeConfig
|
||||
const model = { providerID: "openai", modelID: "gpt-5.2-codex" }
|
||||
|
||||
// #when
|
||||
const variant = resolveVariantForModel(config, "custom-agent", model)
|
||||
|
||||
// #then
|
||||
expect(variant).toBe("xhigh")
|
||||
})
|
||||
|
||||
test("returns correct variant for oracle agent with openai", () => {
|
||||
// #given
|
||||
const config = {} as OhMyOpenCodeConfig
|
||||
const model = { providerID: "openai", modelID: "gpt-5.2" }
|
||||
|
||||
// #when
|
||||
const variant = resolveVariantForModel(config, "oracle", model)
|
||||
|
||||
// #then
|
||||
expect(variant).toBe("high")
|
||||
})
|
||||
|
||||
test("returns correct variant for oracle agent with anthropic", () => {
|
||||
// #given
|
||||
const config = {} as OhMyOpenCodeConfig
|
||||
const model = { providerID: "anthropic", modelID: "claude-opus-4-5" }
|
||||
|
||||
// #when
|
||||
const variant = resolveVariantForModel(config, "oracle", model)
|
||||
|
||||
// #then
|
||||
expect(variant).toBe("max")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { OhMyOpenCodeConfig } from "../config"
|
||||
import { findCaseInsensitive } from "./case-insensitive"
|
||||
import { AGENT_MODEL_REQUIREMENTS, CATEGORY_MODEL_REQUIREMENTS } from "./model-requirements"
|
||||
|
||||
export function resolveAgentVariant(
|
||||
config: OhMyOpenCodeConfig,
|
||||
@@ -29,6 +30,43 @@ export function resolveAgentVariant(
|
||||
return config.categories?.[categoryName]?.variant
|
||||
}
|
||||
|
||||
export function resolveVariantForModel(
|
||||
config: OhMyOpenCodeConfig,
|
||||
agentName: string,
|
||||
currentModel: { providerID: string; modelID: string },
|
||||
): string | undefined {
|
||||
const agentRequirement = AGENT_MODEL_REQUIREMENTS[agentName]
|
||||
if (agentRequirement) {
|
||||
return findVariantInChain(agentRequirement.fallbackChain, currentModel.providerID)
|
||||
}
|
||||
|
||||
const agentOverrides = config.agents as
|
||||
| Record<string, { category?: string }>
|
||||
| undefined
|
||||
const agentOverride = agentOverrides ? findCaseInsensitive(agentOverrides, agentName) : undefined
|
||||
const categoryName = agentOverride?.category
|
||||
if (categoryName) {
|
||||
const categoryRequirement = CATEGORY_MODEL_REQUIREMENTS[categoryName]
|
||||
if (categoryRequirement) {
|
||||
return findVariantInChain(categoryRequirement.fallbackChain, currentModel.providerID)
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function findVariantInChain(
|
||||
fallbackChain: { providers: string[]; model: string; variant?: string }[],
|
||||
providerID: string,
|
||||
): string | undefined {
|
||||
for (const entry of fallbackChain) {
|
||||
if (entry.providers.includes(providerID)) {
|
||||
return entry.variant
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function applyAgentVariant(
|
||||
config: OhMyOpenCodeConfig,
|
||||
agentName: string | undefined,
|
||||
|
||||
@@ -118,6 +118,161 @@ describe("external-plugin-detector", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("false positive prevention", () => {
|
||||
test("should NOT match my-opencode-notifier-fork (suffix variation)", () => {
|
||||
// #given - plugin with similar name but different suffix
|
||||
const opencodeDir = path.join(tempDir, ".opencode")
|
||||
fs.mkdirSync(opencodeDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(opencodeDir, "opencode.json"),
|
||||
JSON.stringify({ plugin: ["my-opencode-notifier-fork"] })
|
||||
)
|
||||
|
||||
// #when
|
||||
const result = detectExternalNotificationPlugin(tempDir)
|
||||
|
||||
// #then
|
||||
expect(result.detected).toBe(false)
|
||||
expect(result.pluginName).toBeNull()
|
||||
})
|
||||
|
||||
test("should NOT match some-other-plugin/opencode-notifier-like (path with similar name)", () => {
|
||||
// #given - plugin path containing similar substring
|
||||
const opencodeDir = path.join(tempDir, ".opencode")
|
||||
fs.mkdirSync(opencodeDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(opencodeDir, "opencode.json"),
|
||||
JSON.stringify({ plugin: ["some-other-plugin/opencode-notifier-like"] })
|
||||
)
|
||||
|
||||
// #when
|
||||
const result = detectExternalNotificationPlugin(tempDir)
|
||||
|
||||
// #then
|
||||
expect(result.detected).toBe(false)
|
||||
expect(result.pluginName).toBeNull()
|
||||
})
|
||||
|
||||
test("should NOT match opencode-notifier-extended (prefix match but different package)", () => {
|
||||
// #given - plugin with prefix match but extended name
|
||||
const opencodeDir = path.join(tempDir, ".opencode")
|
||||
fs.mkdirSync(opencodeDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(opencodeDir, "opencode.json"),
|
||||
JSON.stringify({ plugin: ["opencode-notifier-extended"] })
|
||||
)
|
||||
|
||||
// #when
|
||||
const result = detectExternalNotificationPlugin(tempDir)
|
||||
|
||||
// #then
|
||||
expect(result.detected).toBe(false)
|
||||
expect(result.pluginName).toBeNull()
|
||||
})
|
||||
|
||||
test("should match opencode-notifier exactly", () => {
|
||||
// #given - exact match
|
||||
const opencodeDir = path.join(tempDir, ".opencode")
|
||||
fs.mkdirSync(opencodeDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(opencodeDir, "opencode.json"),
|
||||
JSON.stringify({ plugin: ["opencode-notifier"] })
|
||||
)
|
||||
|
||||
// #when
|
||||
const result = detectExternalNotificationPlugin(tempDir)
|
||||
|
||||
// #then
|
||||
expect(result.detected).toBe(true)
|
||||
expect(result.pluginName).toBe("opencode-notifier")
|
||||
})
|
||||
|
||||
test("should match opencode-notifier@1.2.3 (version suffix)", () => {
|
||||
// #given - version suffix
|
||||
const opencodeDir = path.join(tempDir, ".opencode")
|
||||
fs.mkdirSync(opencodeDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(opencodeDir, "opencode.json"),
|
||||
JSON.stringify({ plugin: ["opencode-notifier@1.2.3"] })
|
||||
)
|
||||
|
||||
// #when
|
||||
const result = detectExternalNotificationPlugin(tempDir)
|
||||
|
||||
// #then
|
||||
expect(result.detected).toBe(true)
|
||||
expect(result.pluginName).toBe("opencode-notifier")
|
||||
})
|
||||
|
||||
test("should match @mohak34/opencode-notifier (scoped package)", () => {
|
||||
// #given - scoped package
|
||||
const opencodeDir = path.join(tempDir, ".opencode")
|
||||
fs.mkdirSync(opencodeDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(opencodeDir, "opencode.json"),
|
||||
JSON.stringify({ plugin: ["@mohak34/opencode-notifier"] })
|
||||
)
|
||||
|
||||
// #when
|
||||
const result = detectExternalNotificationPlugin(tempDir)
|
||||
|
||||
// #then
|
||||
expect(result.detected).toBe(true)
|
||||
expect(result.pluginName).toContain("opencode-notifier")
|
||||
})
|
||||
|
||||
test("should match npm:opencode-notifier (npm prefix)", () => {
|
||||
// #given - npm prefix
|
||||
const opencodeDir = path.join(tempDir, ".opencode")
|
||||
fs.mkdirSync(opencodeDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(opencodeDir, "opencode.json"),
|
||||
JSON.stringify({ plugin: ["npm:opencode-notifier"] })
|
||||
)
|
||||
|
||||
// #when
|
||||
const result = detectExternalNotificationPlugin(tempDir)
|
||||
|
||||
// #then
|
||||
expect(result.detected).toBe(true)
|
||||
expect(result.pluginName).toBe("opencode-notifier")
|
||||
})
|
||||
|
||||
test("should match npm:opencode-notifier@2.0.0 (npm prefix with version)", () => {
|
||||
// #given - npm prefix with version
|
||||
const opencodeDir = path.join(tempDir, ".opencode")
|
||||
fs.mkdirSync(opencodeDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(opencodeDir, "opencode.json"),
|
||||
JSON.stringify({ plugin: ["npm:opencode-notifier@2.0.0"] })
|
||||
)
|
||||
|
||||
// #when
|
||||
const result = detectExternalNotificationPlugin(tempDir)
|
||||
|
||||
// #then
|
||||
expect(result.detected).toBe(true)
|
||||
expect(result.pluginName).toBe("opencode-notifier")
|
||||
})
|
||||
|
||||
test("should match file:///path/to/opencode-notifier (file path)", () => {
|
||||
// #given - file path
|
||||
const opencodeDir = path.join(tempDir, ".opencode")
|
||||
fs.mkdirSync(opencodeDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(opencodeDir, "opencode.json"),
|
||||
JSON.stringify({ plugin: ["file:///home/user/plugins/opencode-notifier"] })
|
||||
)
|
||||
|
||||
// #when
|
||||
const result = detectExternalNotificationPlugin(tempDir)
|
||||
|
||||
// #then
|
||||
expect(result.detected).toBe(true)
|
||||
expect(result.pluginName).toBe("opencode-notifier")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getNotificationConflictWarning", () => {
|
||||
test("should generate warning message with plugin name", () => {
|
||||
// #when
|
||||
|
||||
@@ -71,14 +71,19 @@ function loadOpencodePlugins(directory: string): string[] {
|
||||
function matchesNotificationPlugin(entry: string): string | null {
|
||||
const normalized = entry.toLowerCase()
|
||||
for (const known of KNOWN_NOTIFICATION_PLUGINS) {
|
||||
if (
|
||||
normalized === known ||
|
||||
normalized.startsWith(`${known}@`) ||
|
||||
normalized.includes(`/${known}`) ||
|
||||
normalized.endsWith(`/${known}`)
|
||||
) {
|
||||
return known
|
||||
}
|
||||
// Exact match
|
||||
if (normalized === known) return known
|
||||
// Version suffix: "opencode-notifier@1.2.3"
|
||||
if (normalized.startsWith(`${known}@`)) return known
|
||||
// Scoped package: "@mohak34/opencode-notifier" or "@mohak34/opencode-notifier@1.2.3"
|
||||
if (normalized === `@mohak34/${known}` || normalized.startsWith(`@mohak34/${known}@`)) return known
|
||||
// npm: prefix
|
||||
if (normalized === `npm:${known}` || normalized.startsWith(`npm:${known}@`)) return known
|
||||
// file:// path ending exactly with package name
|
||||
if (normalized.startsWith("file://") && (
|
||||
normalized.endsWith(`/${known}`) ||
|
||||
normalized.endsWith(`\\${known}`)
|
||||
)) return known
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test, spyOn, beforeEach, afterEach } from "bun:test"
|
||||
import { describe, expect, test, spyOn, beforeEach, afterEach, mock } from "bun:test"
|
||||
import { resolveModel, resolveModelWithFallback, type ModelResolutionInput, type ExtendedModelResolutionInput, type ModelResolutionResult, type ModelSource } from "./model-resolver"
|
||||
import * as logger from "./logger"
|
||||
import * as connectedProvidersCache from "./connected-providers-cache"
|
||||
|
||||
describe("resolveModel", () => {
|
||||
describe("priority chain", () => {
|
||||
@@ -128,8 +129,8 @@ describe("resolveModelWithFallback", () => {
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
expect(result.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(result.source).toBe("override")
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(result!.source).toBe("override")
|
||||
expect(logSpy).toHaveBeenCalledWith("Model resolved via override", { model: "anthropic/claude-opus-4-5" })
|
||||
})
|
||||
|
||||
@@ -148,8 +149,8 @@ describe("resolveModelWithFallback", () => {
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
expect(result.model).toBe("custom/my-model")
|
||||
expect(result.source).toBe("override")
|
||||
expect(result!.model).toBe("custom/my-model")
|
||||
expect(result!.source).toBe("override")
|
||||
})
|
||||
|
||||
test("whitespace-only userModel is treated as not provided", () => {
|
||||
@@ -167,7 +168,7 @@ describe("resolveModelWithFallback", () => {
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
expect(result.source).not.toBe("override")
|
||||
expect(result!.source).not.toBe("override")
|
||||
})
|
||||
|
||||
test("empty string userModel is treated as not provided", () => {
|
||||
@@ -185,7 +186,7 @@ describe("resolveModelWithFallback", () => {
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
expect(result.source).not.toBe("override")
|
||||
expect(result!.source).not.toBe("override")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -204,8 +205,8 @@ describe("resolveModelWithFallback", () => {
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
expect(result.model).toBe("github-copilot/claude-opus-4-5-preview")
|
||||
expect(result.source).toBe("provider-fallback")
|
||||
expect(result!.model).toBe("github-copilot/claude-opus-4-5-preview")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
expect(logSpy).toHaveBeenCalledWith("Model resolved via fallback chain (availability confirmed)", {
|
||||
provider: "github-copilot",
|
||||
model: "claude-opus-4-5",
|
||||
@@ -228,8 +229,8 @@ describe("resolveModelWithFallback", () => {
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
expect(result.model).toBe("openai/gpt-5.2")
|
||||
expect(result.source).toBe("provider-fallback")
|
||||
expect(result!.model).toBe("openai/gpt-5.2")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
})
|
||||
|
||||
test("tries next provider when first provider has no match", () => {
|
||||
@@ -246,8 +247,8 @@ describe("resolveModelWithFallback", () => {
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
expect(result.model).toBe("opencode/gpt-5-nano")
|
||||
expect(result.source).toBe("provider-fallback")
|
||||
expect(result!.model).toBe("opencode/gpt-5-nano")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
})
|
||||
|
||||
test("uses fuzzy matching within provider", () => {
|
||||
@@ -264,8 +265,8 @@ describe("resolveModelWithFallback", () => {
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
expect(result.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(result.source).toBe("provider-fallback")
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
})
|
||||
|
||||
test("skips fallback chain when not provided", () => {
|
||||
@@ -279,7 +280,7 @@ describe("resolveModelWithFallback", () => {
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
expect(result.source).toBe("system-default")
|
||||
expect(result!.source).toBe("system-default")
|
||||
})
|
||||
|
||||
test("skips fallback chain when empty", () => {
|
||||
@@ -294,7 +295,7 @@ describe("resolveModelWithFallback", () => {
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
expect(result.source).toBe("system-default")
|
||||
expect(result!.source).toBe("system-default")
|
||||
})
|
||||
|
||||
test("case-insensitive fuzzy matching", () => {
|
||||
@@ -311,8 +312,8 @@ describe("resolveModelWithFallback", () => {
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
expect(result.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(result.source).toBe("provider-fallback")
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -331,13 +332,53 @@ describe("resolveModelWithFallback", () => {
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
expect(result.model).toBe("google/gemini-3-pro")
|
||||
expect(result.source).toBe("system-default")
|
||||
expect(result!.model).toBe("google/gemini-3-pro")
|
||||
expect(result!.source).toBe("system-default")
|
||||
expect(logSpy).toHaveBeenCalledWith("No available model found in fallback chain, falling through to system default")
|
||||
})
|
||||
|
||||
test("uses first fallback entry when availableModels is empty (no cache scenario)", () => {
|
||||
// #given - empty availableModels simulates CI environment without model cache
|
||||
test("returns undefined when availableModels empty and no connected providers cache exists", () => {
|
||||
// #given - both model cache and connected-providers cache are missing (first run)
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-5" },
|
||||
],
|
||||
availableModels: new Set(),
|
||||
systemDefaultModel: undefined, // no system default configured
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then - should return undefined to let OpenCode use Provider.defaultModel()
|
||||
expect(result).toBeUndefined()
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("uses connected provider when availableModels empty but connected providers cache exists", () => {
|
||||
// #given - model cache missing but connected-providers cache exists
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai", "google"])
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "openai"], model: "claude-opus-4-5" },
|
||||
],
|
||||
availableModels: new Set(),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then - should use openai (second provider) since anthropic not in connected cache
|
||||
expect(result!.model).toBe("openai/claude-opus-4-5")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("falls through to system default when no cache and systemDefaultModel is provided", () => {
|
||||
// #given - no cache but system default is configured
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-5" },
|
||||
@@ -349,9 +390,10 @@ describe("resolveModelWithFallback", () => {
|
||||
// #when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then - should use first fallback entry, not system default
|
||||
expect(result.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(result.source).toBe("provider-fallback")
|
||||
// #then - should fall through to system default
|
||||
expect(result!.model).toBe("google/gemini-3-pro")
|
||||
expect(result!.source).toBe("system-default")
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("returns system default when fallbackChain is not provided", () => {
|
||||
@@ -365,8 +407,8 @@ describe("resolveModelWithFallback", () => {
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
expect(result.model).toBe("google/gemini-3-pro")
|
||||
expect(result.source).toBe("system-default")
|
||||
expect(result!.model).toBe("google/gemini-3-pro")
|
||||
expect(result!.source).toBe("system-default")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -386,8 +428,8 @@ describe("resolveModelWithFallback", () => {
|
||||
})
|
||||
|
||||
// #then
|
||||
expect(result.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(result.source).toBe("provider-fallback")
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
})
|
||||
|
||||
test("tries all providers in first entry before moving to second entry", () => {
|
||||
@@ -405,8 +447,8 @@ describe("resolveModelWithFallback", () => {
|
||||
})
|
||||
|
||||
// #then
|
||||
expect(result.model).toBe("google/gemini-3-pro")
|
||||
expect(result.source).toBe("provider-fallback")
|
||||
expect(result!.model).toBe("google/gemini-3-pro")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
})
|
||||
|
||||
test("returns first matching entry even if later entries have better matches", () => {
|
||||
@@ -427,8 +469,8 @@ describe("resolveModelWithFallback", () => {
|
||||
})
|
||||
|
||||
// #then
|
||||
expect(result.model).toBe("openai/gpt-5.2")
|
||||
expect(result.source).toBe("provider-fallback")
|
||||
expect(result!.model).toBe("openai/gpt-5.2")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
})
|
||||
|
||||
test("falls through to system default when none match availability", () => {
|
||||
@@ -447,8 +489,8 @@ describe("resolveModelWithFallback", () => {
|
||||
})
|
||||
|
||||
// #then
|
||||
expect(result.model).toBe("system/default")
|
||||
expect(result.source).toBe("system-default")
|
||||
expect(result!.model).toBe("system/default")
|
||||
expect(result!.source).toBe("system-default")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -462,11 +504,81 @@ describe("resolveModelWithFallback", () => {
|
||||
}
|
||||
|
||||
// #when
|
||||
const result: ModelResolutionResult = resolveModelWithFallback(input)
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
expect(typeof result.model).toBe("string")
|
||||
expect(["override", "provider-fallback", "system-default"]).toContain(result.source)
|
||||
expect(result).toBeDefined()
|
||||
expect(typeof result!.model).toBe("string")
|
||||
expect(["override", "provider-fallback", "system-default"]).toContain(result!.source)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Optional systemDefaultModel", () => {
|
||||
test("returns undefined when systemDefaultModel is undefined and no fallback found", () => {
|
||||
// #given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic"], model: "nonexistent-model" },
|
||||
],
|
||||
availableModels: new Set(["openai/gpt-5.2"]),
|
||||
systemDefaultModel: undefined,
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns undefined when no fallbackChain and systemDefaultModel is undefined", () => {
|
||||
// #given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
availableModels: new Set(["openai/gpt-5.2"]),
|
||||
systemDefaultModel: undefined,
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
test("still returns override when userModel provided even if systemDefaultModel undefined", () => {
|
||||
// #given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
userModel: "anthropic/claude-opus-4-5",
|
||||
availableModels: new Set(),
|
||||
systemDefaultModel: undefined,
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(result!.source).toBe("override")
|
||||
})
|
||||
|
||||
test("still returns fallback match when systemDefaultModel undefined", () => {
|
||||
// #given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-5" },
|
||||
],
|
||||
availableModels: new Set(["anthropic/claude-opus-4-5"]),
|
||||
systemDefaultModel: undefined,
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// #then
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import { readConnectedProvidersCache } from "./connected-providers-cache"
|
||||
export type ModelResolutionInput = {
|
||||
userModel?: string
|
||||
inheritedModel?: string
|
||||
systemDefault: string
|
||||
systemDefault?: string
|
||||
}
|
||||
|
||||
export type ModelSource =
|
||||
@@ -24,7 +24,7 @@ export type ExtendedModelResolutionInput = {
|
||||
userModel?: string
|
||||
fallbackChain?: FallbackEntry[]
|
||||
availableModels: Set<string>
|
||||
systemDefaultModel: string
|
||||
systemDefaultModel?: string
|
||||
}
|
||||
|
||||
function normalizeModel(model?: string): string | undefined {
|
||||
@@ -32,7 +32,7 @@ function normalizeModel(model?: string): string | undefined {
|
||||
return trimmed || undefined
|
||||
}
|
||||
|
||||
export function resolveModel(input: ModelResolutionInput): string {
|
||||
export function resolveModel(input: ModelResolutionInput): string | undefined {
|
||||
return (
|
||||
normalizeModel(input.userModel) ??
|
||||
normalizeModel(input.inheritedModel) ??
|
||||
@@ -42,7 +42,7 @@ export function resolveModel(input: ModelResolutionInput): string {
|
||||
|
||||
export function resolveModelWithFallback(
|
||||
input: ExtendedModelResolutionInput,
|
||||
): ModelResolutionResult {
|
||||
): ModelResolutionResult | undefined {
|
||||
const { userModel, fallbackChain, availableModels, systemDefaultModel } = input
|
||||
|
||||
// Step 1: Override
|
||||
@@ -58,25 +58,26 @@ export function resolveModelWithFallback(
|
||||
const connectedProviders = readConnectedProvidersCache()
|
||||
const connectedSet = connectedProviders ? new Set(connectedProviders) : null
|
||||
|
||||
for (const entry of fallbackChain) {
|
||||
for (const provider of entry.providers) {
|
||||
if (connectedSet === null || connectedSet.has(provider)) {
|
||||
const model = `${provider}/${entry.model}`
|
||||
log("Model resolved via fallback chain (no model cache, using connected provider)", {
|
||||
provider,
|
||||
model: entry.model,
|
||||
variant: entry.variant,
|
||||
hasConnectedCache: connectedSet !== null
|
||||
})
|
||||
return { model, source: "provider-fallback", variant: entry.variant }
|
||||
// When no cache exists at all, skip fallback chain and fall through to system default
|
||||
// This allows OpenCode to use Provider.defaultModel() as the final fallback
|
||||
if (connectedSet === null) {
|
||||
log("No cache available, skipping fallback chain to use system default")
|
||||
} else {
|
||||
for (const entry of fallbackChain) {
|
||||
for (const provider of entry.providers) {
|
||||
if (connectedSet.has(provider)) {
|
||||
const model = `${provider}/${entry.model}`
|
||||
log("Model resolved via fallback chain (no model cache, using connected provider)", {
|
||||
provider,
|
||||
model: entry.model,
|
||||
variant: entry.variant,
|
||||
})
|
||||
return { model, source: "provider-fallback", variant: entry.variant }
|
||||
}
|
||||
}
|
||||
}
|
||||
log("No matching provider in connected cache, falling through to system default")
|
||||
}
|
||||
const firstEntry = fallbackChain[0]
|
||||
const firstProvider = firstEntry.providers[0]
|
||||
const model = `${firstProvider}/${firstEntry.model}`
|
||||
log("Model resolved via fallback chain (no cache at all, using first entry)", { provider: firstProvider, model: firstEntry.model, variant: firstEntry.variant })
|
||||
return { model, source: "provider-fallback", variant: firstEntry.variant }
|
||||
}
|
||||
|
||||
for (const entry of fallbackChain) {
|
||||
@@ -92,7 +93,12 @@ export function resolveModelWithFallback(
|
||||
log("No available model found in fallback chain, falling through to system default")
|
||||
}
|
||||
|
||||
// Step 4: System default
|
||||
// Step 3: System default (if provided)
|
||||
if (systemDefaultModel === undefined) {
|
||||
log("No model resolved - systemDefaultModel not configured")
|
||||
return undefined
|
||||
}
|
||||
|
||||
log("Model resolved via system default", { model: systemDefaultModel })
|
||||
return { model: systemDefaultModel, source: "system-default" }
|
||||
}
|
||||
|
||||
198
src/shared/ollama-ndjson-parser.ts
Normal file
198
src/shared/ollama-ndjson-parser.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Ollama NDJSON Parser
|
||||
*
|
||||
* Parses newline-delimited JSON (NDJSON) responses from Ollama API.
|
||||
*
|
||||
* @module ollama-ndjson-parser
|
||||
* @see https://github.com/code-yeongyu/oh-my-opencode/issues/1124
|
||||
* @see https://github.com/ollama/ollama/blob/main/docs/api.md
|
||||
*/
|
||||
|
||||
import { log } from "./logger"
|
||||
|
||||
/**
|
||||
* Ollama message structure
|
||||
*/
|
||||
export interface OllamaMessage {
|
||||
tool_calls?: Array<{
|
||||
function: {
|
||||
name: string
|
||||
arguments: Record<string, unknown>
|
||||
}
|
||||
}>
|
||||
content?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Ollama NDJSON line structure
|
||||
*/
|
||||
export interface OllamaNDJSONLine {
|
||||
message?: OllamaMessage
|
||||
done: boolean
|
||||
total_duration?: number
|
||||
load_duration?: number
|
||||
prompt_eval_count?: number
|
||||
prompt_eval_duration?: number
|
||||
eval_count?: number
|
||||
eval_duration?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Merged Ollama response
|
||||
*/
|
||||
export interface OllamaMergedResponse {
|
||||
message: OllamaMessage
|
||||
done: boolean
|
||||
stats?: {
|
||||
total_duration?: number
|
||||
load_duration?: number
|
||||
prompt_eval_count?: number
|
||||
prompt_eval_duration?: number
|
||||
eval_count?: number
|
||||
eval_duration?: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Ollama streaming NDJSON response into a single merged object.
|
||||
*
|
||||
* Ollama returns streaming responses as newline-delimited JSON (NDJSON):
|
||||
* ```
|
||||
* {"message":{"tool_calls":[...]}, "done":false}
|
||||
* {"message":{"content":""}, "done":true}
|
||||
* ```
|
||||
*
|
||||
* This function:
|
||||
* 1. Splits the response by newlines
|
||||
* 2. Parses each line as JSON
|
||||
* 3. Merges tool_calls and content from all lines
|
||||
* 4. Returns a single merged response
|
||||
*
|
||||
* @param response - Raw NDJSON response string from Ollama API
|
||||
* @returns Merged response with all tool_calls and content combined
|
||||
* @throws {Error} If no valid JSON lines are found
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const ndjsonResponse = `
|
||||
* {"message":{"tool_calls":[{"function":{"name":"read","arguments":{"filePath":"README.md"}}}]}, "done":false}
|
||||
* {"message":{"content":""}, "done":true}
|
||||
* `;
|
||||
*
|
||||
* const merged = parseOllamaStreamResponse(ndjsonResponse);
|
||||
* // Result:
|
||||
* // {
|
||||
* // message: {
|
||||
* // tool_calls: [{ function: { name: "read", arguments: { filePath: "README.md" } } }],
|
||||
* // content: ""
|
||||
* // },
|
||||
* // done: true
|
||||
* // }
|
||||
* ```
|
||||
*/
|
||||
export function parseOllamaStreamResponse(response: string): OllamaMergedResponse {
|
||||
const lines = response.split("\n").filter((line) => line.trim())
|
||||
|
||||
if (lines.length === 0) {
|
||||
throw new Error("No valid NDJSON lines found in response")
|
||||
}
|
||||
|
||||
const mergedMessage: OllamaMessage = {
|
||||
tool_calls: [],
|
||||
content: "",
|
||||
}
|
||||
|
||||
let done = false
|
||||
let stats: OllamaMergedResponse["stats"] = {}
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const json = JSON.parse(line) as OllamaNDJSONLine
|
||||
|
||||
// Merge tool_calls
|
||||
if (json.message?.tool_calls) {
|
||||
mergedMessage.tool_calls = [
|
||||
...(mergedMessage.tool_calls || []),
|
||||
...json.message.tool_calls,
|
||||
]
|
||||
}
|
||||
|
||||
// Merge content (concatenate)
|
||||
if (json.message?.content) {
|
||||
mergedMessage.content = (mergedMessage.content || "") + json.message.content
|
||||
}
|
||||
|
||||
// Update done flag (final line has done: true)
|
||||
if (json.done) {
|
||||
done = true
|
||||
|
||||
// Capture stats from final line
|
||||
stats = {
|
||||
total_duration: json.total_duration,
|
||||
load_duration: json.load_duration,
|
||||
prompt_eval_count: json.prompt_eval_count,
|
||||
prompt_eval_duration: json.prompt_eval_duration,
|
||||
eval_count: json.eval_count,
|
||||
eval_duration: json.eval_duration,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log(`[ollama-ndjson-parser] Skipping malformed NDJSON line: ${line}`, { error })
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
message: mergedMessage,
|
||||
done,
|
||||
...(Object.keys(stats).length > 0 ? { stats } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a response string is NDJSON format.
|
||||
*
|
||||
* NDJSON is identified by:
|
||||
* - Multiple lines
|
||||
* - Each line is valid JSON
|
||||
* - At least one line has "done" field
|
||||
*
|
||||
* @param response - Response string to check
|
||||
* @returns true if response appears to be NDJSON
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const ndjson = '{"done":false}\n{"done":true}';
|
||||
* const singleJson = '{"done":true}';
|
||||
*
|
||||
* isNDJSONResponse(ndjson); // true
|
||||
* isNDJSONResponse(singleJson); // false
|
||||
* ```
|
||||
*/
|
||||
export function isNDJSONResponse(response: string): boolean {
|
||||
const lines = response.split("\n").filter((line) => line.trim())
|
||||
|
||||
// Single line is not NDJSON
|
||||
if (lines.length <= 1) {
|
||||
return false
|
||||
}
|
||||
|
||||
let hasValidJSON = false
|
||||
let hasDoneField = false
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const json = JSON.parse(line) as Record<string, unknown>
|
||||
hasValidJSON = true
|
||||
|
||||
if ("done" in json) {
|
||||
hasDoneField = true
|
||||
}
|
||||
} catch {
|
||||
// If any line fails to parse, it's not NDJSON
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return hasValidJSON && hasDoneField
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
resetVersionCache,
|
||||
setVersionCache,
|
||||
MINIMUM_OPENCODE_VERSION,
|
||||
OPENCODE_NATIVE_AGENTS_INJECTION_VERSION,
|
||||
} from "./opencode-version"
|
||||
|
||||
describe("opencode-version", () => {
|
||||
@@ -220,4 +221,46 @@ describe("opencode-version", () => {
|
||||
expect(MINIMUM_OPENCODE_VERSION).toBe("1.1.1")
|
||||
})
|
||||
})
|
||||
|
||||
describe("OPENCODE_NATIVE_AGENTS_INJECTION_VERSION", () => {
|
||||
test("is set to 1.1.37", () => {
|
||||
// #given the native agents injection version constant
|
||||
// #when exported
|
||||
// #then it should be 1.1.37 (PR #10678)
|
||||
expect(OPENCODE_NATIVE_AGENTS_INJECTION_VERSION).toBe("1.1.37")
|
||||
})
|
||||
|
||||
test("version detection works correctly with native agents version", () => {
|
||||
// #given OpenCode version at or above native agents injection version
|
||||
setVersionCache("1.1.37")
|
||||
|
||||
// #when checking against native agents version
|
||||
const result = isOpenCodeVersionAtLeast(OPENCODE_NATIVE_AGENTS_INJECTION_VERSION)
|
||||
|
||||
// #then returns true (native support available)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("version detection returns false for older versions", () => {
|
||||
// #given OpenCode version below native agents injection version
|
||||
setVersionCache("1.1.36")
|
||||
|
||||
// #when checking against native agents version
|
||||
const result = isOpenCodeVersionAtLeast(OPENCODE_NATIVE_AGENTS_INJECTION_VERSION)
|
||||
|
||||
// #then returns false (no native support)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns true when version detection fails (fail-safe)", () => {
|
||||
// #given version cannot be detected
|
||||
setVersionCache(null)
|
||||
|
||||
// #when checking against native agents version
|
||||
const result = isOpenCodeVersionAtLeast(OPENCODE_NATIVE_AGENTS_INJECTION_VERSION)
|
||||
|
||||
// #then returns true (assume latest, enable native support)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,15 @@ import { execSync } from "child_process"
|
||||
*/
|
||||
export const MINIMUM_OPENCODE_VERSION = "1.1.1"
|
||||
|
||||
/**
|
||||
* OpenCode version that introduced native AGENTS.md injection.
|
||||
* PR #10678 merged on Jan 26, 2026 - OpenCode now dynamically resolves
|
||||
* AGENTS.md files from subdirectories as the agent explores them.
|
||||
* When this version is detected, the directory-agents-injector hook
|
||||
* is auto-disabled to prevent duplicate AGENTS.md loading.
|
||||
*/
|
||||
export const OPENCODE_NATIVE_AGENTS_INJECTION_VERSION = "1.1.37"
|
||||
|
||||
const NOT_CACHED = Symbol("NOT_CACHED")
|
||||
let cachedVersion: string | null | typeof NOT_CACHED = NOT_CACHED
|
||||
|
||||
|
||||
191
src/shared/system-directive.test.ts
Normal file
191
src/shared/system-directive.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
hasSystemReminder,
|
||||
removeSystemReminders,
|
||||
isSystemDirective,
|
||||
createSystemDirective,
|
||||
} from "./system-directive"
|
||||
|
||||
describe("system-directive utilities", () => {
|
||||
describe("hasSystemReminder", () => {
|
||||
test("should return true for messages containing <system-reminder> tags", () => {
|
||||
const text = `<system-reminder>
|
||||
Some system content
|
||||
</system-reminder>`
|
||||
expect(hasSystemReminder(text)).toBe(true)
|
||||
})
|
||||
|
||||
test("should return false for messages without system-reminder tags", () => {
|
||||
const text = "Just a normal user message"
|
||||
expect(hasSystemReminder(text)).toBe(false)
|
||||
})
|
||||
|
||||
test("should be case-insensitive for tag names", () => {
|
||||
const text = `<SYSTEM-REMINDER>content</SYSTEM-REMINDER>`
|
||||
expect(hasSystemReminder(text)).toBe(true)
|
||||
})
|
||||
|
||||
test("should detect system-reminder in mixed content", () => {
|
||||
const text = `User text here
|
||||
<system-reminder>
|
||||
System content
|
||||
</system-reminder>
|
||||
More user text`
|
||||
expect(hasSystemReminder(text)).toBe(true)
|
||||
})
|
||||
|
||||
test("should handle empty system-reminder tags", () => {
|
||||
const text = `<system-reminder></system-reminder>`
|
||||
expect(hasSystemReminder(text)).toBe(true)
|
||||
})
|
||||
|
||||
test("should handle multiline system-reminder content", () => {
|
||||
const text = `<system-reminder>
|
||||
Line 1
|
||||
Line 2
|
||||
Line 3
|
||||
</system-reminder>`
|
||||
expect(hasSystemReminder(text)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("removeSystemReminders", () => {
|
||||
test("should remove system-reminder tags and content", () => {
|
||||
const text = `<system-reminder>
|
||||
System content that should be removed
|
||||
</system-reminder>`
|
||||
expect(removeSystemReminders(text)).toBe("")
|
||||
})
|
||||
|
||||
test("should preserve user text outside system-reminder tags", () => {
|
||||
const text = `User message here
|
||||
<system-reminder>
|
||||
System content to remove
|
||||
</system-reminder>
|
||||
More user text`
|
||||
const result = removeSystemReminders(text)
|
||||
expect(result).toContain("User message here")
|
||||
expect(result).toContain("More user text")
|
||||
expect(result).not.toContain("System content to remove")
|
||||
})
|
||||
|
||||
test("should remove multiple system-reminder blocks", () => {
|
||||
const text = `<system-reminder>First block</system-reminder>
|
||||
User text
|
||||
<system-reminder>Second block</system-reminder>`
|
||||
const result = removeSystemReminders(text)
|
||||
expect(result).toContain("User text")
|
||||
expect(result).not.toContain("First block")
|
||||
expect(result).not.toContain("Second block")
|
||||
})
|
||||
|
||||
test("should be case-insensitive for tag names", () => {
|
||||
const text = `<SYSTEM-REMINDER>Content</SYSTEM-REMINDER>`
|
||||
expect(removeSystemReminders(text)).toBe("")
|
||||
})
|
||||
|
||||
test("should handle nested tags correctly", () => {
|
||||
const text = `<system-reminder>
|
||||
Outer content
|
||||
<inner>Some inner tag</inner>
|
||||
</system-reminder>`
|
||||
expect(removeSystemReminders(text)).toBe("")
|
||||
})
|
||||
|
||||
test("should trim whitespace from result", () => {
|
||||
const text = `
|
||||
<system-reminder>Remove this</system-reminder>
|
||||
|
||||
User text
|
||||
|
||||
`
|
||||
const result = removeSystemReminders(text)
|
||||
expect(result).toBe("User text")
|
||||
})
|
||||
|
||||
test("should handle empty string input", () => {
|
||||
expect(removeSystemReminders("")).toBe("")
|
||||
})
|
||||
|
||||
test("should handle text with no system-reminder tags", () => {
|
||||
const text = "Just normal user text without any system reminders"
|
||||
expect(removeSystemReminders(text)).toBe(text)
|
||||
})
|
||||
|
||||
test("should preserve code blocks in user text", () => {
|
||||
const text = `Here's some code:
|
||||
\`\`\`javascript
|
||||
const x = 1;
|
||||
\`\`\`
|
||||
<system-reminder>System info</system-reminder>`
|
||||
const result = removeSystemReminders(text)
|
||||
expect(result).toContain("Here's some code:")
|
||||
expect(result).toContain("```javascript")
|
||||
expect(result).not.toContain("System info")
|
||||
})
|
||||
})
|
||||
|
||||
describe("isSystemDirective", () => {
|
||||
test("should return true for OH-MY-OPENCODE system directives", () => {
|
||||
const directive = createSystemDirective("TEST")
|
||||
expect(isSystemDirective(directive)).toBe(true)
|
||||
})
|
||||
|
||||
test("should return false for system-reminder tags", () => {
|
||||
const text = `<system-reminder>content</system-reminder>`
|
||||
expect(isSystemDirective(text)).toBe(false)
|
||||
})
|
||||
|
||||
test("should return false for normal user messages", () => {
|
||||
expect(isSystemDirective("Just a normal message")).toBe(false)
|
||||
})
|
||||
|
||||
test("should handle leading whitespace", () => {
|
||||
const directive = ` ${createSystemDirective("TEST")}`
|
||||
expect(isSystemDirective(directive)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("integration with keyword detection", () => {
|
||||
test("should prevent search keywords in system-reminders from triggering mode", () => {
|
||||
const text = `<system-reminder>
|
||||
The system will search for the file and find all occurrences.
|
||||
Please locate and scan the directory.
|
||||
</system-reminder>`
|
||||
|
||||
// After removing system reminders, no search keywords should remain
|
||||
const cleanText = removeSystemReminders(text)
|
||||
expect(cleanText).not.toMatch(/\b(search|find|locate|scan)\b/i)
|
||||
})
|
||||
|
||||
test("should preserve search keywords in user text while removing system-reminder keywords", () => {
|
||||
const text = `<system-reminder>
|
||||
System will find and locate files.
|
||||
</system-reminder>
|
||||
|
||||
Please search for the bug in the code.`
|
||||
|
||||
const cleanText = removeSystemReminders(text)
|
||||
expect(cleanText).toContain("search")
|
||||
expect(cleanText).not.toContain("find and locate")
|
||||
})
|
||||
|
||||
test("should handle complex mixed content with multiple modes", () => {
|
||||
const text = `<system-reminder>
|
||||
System will search and investigate.
|
||||
</system-reminder>
|
||||
|
||||
User wants to explore the codebase and analyze the implementation.
|
||||
|
||||
<system-reminder>
|
||||
Another system reminder with research keyword.
|
||||
</system-reminder>`
|
||||
|
||||
const cleanText = removeSystemReminders(text)
|
||||
expect(cleanText).toContain("explore")
|
||||
expect(cleanText).toContain("analyze")
|
||||
expect(cleanText).not.toContain("search and investigate")
|
||||
expect(cleanText).not.toContain("research")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -26,6 +26,26 @@ export function isSystemDirective(text: string): boolean {
|
||||
return text.trimStart().startsWith(SYSTEM_DIRECTIVE_PREFIX)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a message contains system-generated content that should be excluded
|
||||
* from keyword detection and mode triggering.
|
||||
* @param text - The message text to check
|
||||
* @returns true if the message contains system-reminder tags
|
||||
*/
|
||||
export function hasSystemReminder(text: string): boolean {
|
||||
return /<system-reminder>[\s\S]*?<\/system-reminder>/i.test(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes system-reminder tag content from text.
|
||||
* This prevents automated system messages from triggering mode keywords.
|
||||
* @param text - The message text to clean
|
||||
* @returns text with system-reminder content removed
|
||||
*/
|
||||
export function removeSystemReminders(text: string): string {
|
||||
return text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, "").trim()
|
||||
}
|
||||
|
||||
export const SystemDirectiveTypes = {
|
||||
TODO_CONTINUATION: "TODO CONTINUATION",
|
||||
RALPH_LOOP: "RALPH LOOP",
|
||||
|
||||
@@ -163,7 +163,10 @@ async function executeSync(
|
||||
body: {
|
||||
parentID: toolContext.sessionID,
|
||||
title: `${args.description} (@${args.subagent_type} subagent)`,
|
||||
},
|
||||
permission: [
|
||||
{ permission: "question", action: "deny" as const, pattern: "*" },
|
||||
],
|
||||
} as any,
|
||||
query: {
|
||||
directory: parentDirectory,
|
||||
},
|
||||
@@ -171,6 +174,17 @@ async function executeSync(
|
||||
|
||||
if (createResult.error) {
|
||||
log(`[call_omo_agent] Session create error:`, createResult.error)
|
||||
const errorStr = String(createResult.error)
|
||||
if (errorStr.toLowerCase().includes("unauthorized")) {
|
||||
return `Error: Failed to create session (Unauthorized). This may be due to:
|
||||
1. OAuth token restrictions (e.g., Claude Code credentials are restricted to Claude Code only)
|
||||
2. Provider authentication issues
|
||||
3. Session permission inheritance problems
|
||||
|
||||
Try using a different provider or API key authentication.
|
||||
|
||||
Original error: ${createResult.error}`
|
||||
}
|
||||
return `Error: Failed to create session: ${createResult.error}`
|
||||
}
|
||||
|
||||
|
||||
@@ -185,4 +185,237 @@ export const CATEGORY_DESCRIPTIONS: Record<string, string> = {
|
||||
writing: "Documentation, prose, technical writing",
|
||||
}
|
||||
|
||||
/**
|
||||
* System prompt prepended to plan agent invocations.
|
||||
* Instructs the plan agent to first gather context via explore/librarian agents,
|
||||
* then summarize user requirements and clarify uncertainties before proceeding.
|
||||
* Also MANDATES dependency graphs, parallel execution analysis, and category+skill recommendations.
|
||||
*/
|
||||
export const PLAN_AGENT_SYSTEM_PREPEND = `<system>
|
||||
BEFORE you begin planning, you MUST first understand the user's request deeply.
|
||||
|
||||
MANDATORY CONTEXT GATHERING PROTOCOL:
|
||||
1. Launch background agents to gather context:
|
||||
- call_omo_agent(description="Explore codebase patterns", subagent_type="explore", run_in_background=true, prompt="<search for relevant patterns, files, and implementations in the codebase related to user's request>")
|
||||
- call_omo_agent(description="Research documentation", subagent_type="librarian", run_in_background=true, prompt="<search for external documentation, examples, and best practices related to user's request>")
|
||||
|
||||
2. After gathering context, ALWAYS present:
|
||||
- **User Request Summary**: Concise restatement of what the user is asking for
|
||||
- **Uncertainties**: List of unclear points, ambiguities, or assumptions you're making
|
||||
- **Clarifying Questions**: Specific questions to resolve the uncertainties
|
||||
|
||||
3. ITERATE until ALL requirements are crystal clear:
|
||||
- Do NOT proceed to planning until you have 100% clarity
|
||||
- Ask the user to confirm your understanding
|
||||
- Resolve every ambiguity before generating the work plan
|
||||
|
||||
REMEMBER: Vague requirements lead to failed implementations. Take the time to understand thoroughly.
|
||||
</system>
|
||||
|
||||
<CRITICAL_REQUIREMENT_DEPENDENCY_PARALLEL_EXECUTION_CATEGORY_SKILLS>
|
||||
#####################################################################
|
||||
# #
|
||||
# ██████╗ ███████╗ ██████╗ ██╗ ██╗██╗██████╗ ███████╗██████╗ #
|
||||
# ██╔══██╗██╔════╝██╔═══██╗██║ ██║██║██╔══██╗██╔════╝██╔══██╗ #
|
||||
# ██████╔╝█████╗ ██║ ██║██║ ██║██║██████╔╝█████╗ ██║ ██║ #
|
||||
# ██╔══██╗██╔══╝ ██║▄▄ ██║██║ ██║██║██╔══██╗██╔══╝ ██║ ██║ #
|
||||
# ██<E29688><E29688> ██║███████╗╚██████╔╝╚██████╔╝██║██║ ██║███████╗██████╔╝ #
|
||||
# ╚═╝ ╚═╝╚══════╝ ╚══▀▀═╝ ╚═════╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═════╝ #
|
||||
# #
|
||||
#####################################################################
|
||||
|
||||
YOU MUST INCLUDE THE FOLLOWING SECTIONS IN YOUR PLAN OUTPUT.
|
||||
THIS IS NON-NEGOTIABLE. FAILURE TO INCLUDE THESE SECTIONS = INCOMPLETE PLAN.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
█ SECTION 1: TASK DEPENDENCY GRAPH (MANDATORY) █
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
YOU MUST ANALYZE AND DOCUMENT TASK DEPENDENCIES.
|
||||
|
||||
For EVERY task in your plan, you MUST specify:
|
||||
- Which tasks it DEPENDS ON (blockers)
|
||||
- Which tasks DEPEND ON IT (dependents)
|
||||
- The REASON for each dependency
|
||||
|
||||
Example format:
|
||||
\`\`\`
|
||||
## Task Dependency Graph
|
||||
|
||||
| Task | Depends On | Reason |
|
||||
|------|------------|--------|
|
||||
| Task 1 | None | Starting point, no prerequisites |
|
||||
| Task 2 | Task 1 | Requires output/artifact from Task 1 |
|
||||
| Task 3 | Task 1 | Uses same foundation established in Task 1 |
|
||||
| Task 4 | Task 2, Task 3 | Integrates results from both tasks |
|
||||
\`\`\`
|
||||
|
||||
WHY THIS MATTERS:
|
||||
- Executors need to know execution ORDER
|
||||
- Prevents blocked work from starting prematurely
|
||||
- Identifies critical path for project timeline
|
||||
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
█ SECTION 2: PARALLEL EXECUTION GRAPH (MANDATORY) █
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
YOU MUST IDENTIFY WHICH TASKS CAN RUN IN PARALLEL.
|
||||
|
||||
Analyze your dependency graph and group tasks into PARALLEL EXECUTION WAVES:
|
||||
|
||||
Example format:
|
||||
\`\`\`
|
||||
## Parallel Execution Graph
|
||||
|
||||
Wave 1 (Start immediately):
|
||||
├── Task 1: [description] (no dependencies)
|
||||
└── Task 5: [description] (no dependencies)
|
||||
|
||||
Wave 2 (After Wave 1 completes):
|
||||
├── Task 2: [description] (depends: Task 1)
|
||||
├── Task 3: [description] (depends: Task 1)
|
||||
└── Task 6: [description] (depends: Task 5)
|
||||
|
||||
Wave 3 (After Wave 2 completes):
|
||||
└── Task 4: [description] (depends: Task 2, Task 3)
|
||||
|
||||
Critical Path: Task 1 → Task 2 → Task 4
|
||||
Estimated Parallel Speedup: 40% faster than sequential
|
||||
\`\`\`
|
||||
|
||||
WHY THIS MATTERS:
|
||||
- MASSIVE time savings through parallelization
|
||||
- Executors can dispatch multiple agents simultaneously
|
||||
- Identifies bottlenecks in the execution plan
|
||||
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
█ SECTION 3: CATEGORY + SKILLS RECOMMENDATIONS (MANDATORY) █
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
FOR EVERY TASK, YOU MUST RECOMMEND:
|
||||
1. Which CATEGORY to use for delegation
|
||||
2. Which SKILLS to load for the delegated agent
|
||||
|
||||
### AVAILABLE CATEGORIES
|
||||
|
||||
| Category | Best For | Model |
|
||||
|----------|----------|-------|
|
||||
| \`visual-engineering\` | Frontend, UI/UX, design, styling, animation | google/gemini-3-pro |
|
||||
| \`ultrabrain\` | Complex architecture, deep logical reasoning | openai/gpt-5.2-codex |
|
||||
| \`artistry\` | Highly creative/artistic tasks, novel ideas | google/gemini-3-pro |
|
||||
| \`quick\` | Trivial tasks - single file, typo fixes | anthropic/claude-haiku-4-5 |
|
||||
| \`unspecified-low\` | Moderate effort, doesn't fit other categories | anthropic/claude-sonnet-4-5 |
|
||||
| \`unspecified-high\` | High effort, doesn't fit other categories | anthropic/claude-opus-4-5 |
|
||||
| \`writing\` | Documentation, prose, technical writing | google/gemini-3-flash |
|
||||
|
||||
### AVAILABLE SKILLS (ALWAYS EVALUATE ALL)
|
||||
|
||||
Skills inject specialized expertise into the delegated agent.
|
||||
YOU MUST evaluate EVERY skill and justify inclusions/omissions.
|
||||
|
||||
| Skill | Domain |
|
||||
|-------|--------|
|
||||
| \`agent-browser\` | Browser automation, web testing |
|
||||
| \`frontend-ui-ux\` | Stunning UI/UX design |
|
||||
| \`git-master\` | Atomic commits, git operations |
|
||||
| \`dev-browser\` | Persistent browser state automation |
|
||||
| \`typescript-programmer\` | Production TypeScript code |
|
||||
| \`python-programmer\` | Production Python code |
|
||||
| \`svelte-programmer\` | Svelte components |
|
||||
| \`golang-tui-programmer\` | Go TUI with Charmbracelet |
|
||||
| \`python-debugger\` | Interactive Python debugging |
|
||||
| \`data-scientist\` | DuckDB/Polars data processing |
|
||||
| \`prompt-engineer\` | AI prompt optimization |
|
||||
|
||||
### REQUIRED OUTPUT FORMAT
|
||||
|
||||
For EACH task, include a recommendation block:
|
||||
|
||||
\`\`\`
|
||||
### Task N: [Task Title]
|
||||
|
||||
**Delegation Recommendation:**
|
||||
- Category: \`[category-name]\` - [reason for choice]
|
||||
- Skills: [\`skill-1\`, \`skill-2\`] - [reason each skill is needed]
|
||||
|
||||
**Skills Evaluation:**
|
||||
- INCLUDED \`skill-name\`: [reason]
|
||||
- OMITTED \`other-skill\`: [reason domain doesn't overlap]
|
||||
\`\`\`
|
||||
|
||||
WHY THIS MATTERS:
|
||||
- Category determines the MODEL used for execution
|
||||
- Skills inject SPECIALIZED KNOWLEDGE into the executor
|
||||
- Missing a relevant skill = suboptimal execution
|
||||
- Wrong category = wrong model = poor results
|
||||
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
█ RESPONSE FORMAT SPECIFICATION (MANDATORY) █
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
YOUR PLAN OUTPUT MUST FOLLOW THIS EXACT STRUCTURE:
|
||||
|
||||
\`\`\`markdown
|
||||
# [Plan Title]
|
||||
|
||||
## Context
|
||||
[User request summary, interview findings, research results]
|
||||
|
||||
## Task Dependency Graph
|
||||
[Dependency table - see Section 1]
|
||||
|
||||
## Parallel Execution Graph
|
||||
[Wave structure - see Section 2]
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1: [Title]
|
||||
**Description**: [What to do]
|
||||
**Delegation Recommendation**:
|
||||
- Category: \`[category]\` - [reason]
|
||||
- Skills: [\`skill-1\`] - [reason]
|
||||
**Skills Evaluation**: [✅ included / ❌ omitted with reasons]
|
||||
**Depends On**: [Task IDs or "None"]
|
||||
**Acceptance Criteria**: [Verifiable conditions]
|
||||
|
||||
### Task 2: [Title]
|
||||
[Same structure...]
|
||||
|
||||
## Commit Strategy
|
||||
[How to commit changes atomically]
|
||||
|
||||
## Success Criteria
|
||||
[Final verification steps]
|
||||
\`\`\`
|
||||
|
||||
#####################################################################
|
||||
# #
|
||||
# FAILURE TO INCLUDE THESE SECTIONS = PLAN WILL BE REJECTED #
|
||||
# BY MOMUS REVIEW. DO NOT SKIP. DO NOT ABBREVIATE. #
|
||||
# #
|
||||
#####################################################################
|
||||
</CRITICAL_REQUIREMENT_DEPENDENCY_PARALLEL_EXECUTION_CATEGORY_SKILLS>
|
||||
|
||||
`
|
||||
|
||||
/**
|
||||
* List of agent names that should be treated as plan agents.
|
||||
* Case-insensitive matching is used.
|
||||
*/
|
||||
export const PLAN_AGENT_NAMES = ["plan", "prometheus", "planner"]
|
||||
|
||||
/**
|
||||
* Check if the given agent name is a plan agent.
|
||||
* @param agentName - The agent name to check
|
||||
* @returns true if the agent is a plan agent
|
||||
*/
|
||||
export function isPlanAgent(agentName: string | undefined): boolean {
|
||||
if (!agentName) return false
|
||||
const lowerName = agentName.toLowerCase().trim()
|
||||
return PLAN_AGENT_NAMES.some(name => lowerName === name || lowerName.includes(name))
|
||||
}
|
||||
|
||||
|
||||
|
||||
39
src/tools/delegate-task/timing.ts
Normal file
39
src/tools/delegate-task/timing.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
let POLL_INTERVAL_MS = 500
|
||||
let MIN_STABILITY_TIME_MS = 10000
|
||||
let STABILITY_POLLS_REQUIRED = 3
|
||||
let WAIT_FOR_SESSION_INTERVAL_MS = 100
|
||||
let WAIT_FOR_SESSION_TIMEOUT_MS = 30000
|
||||
let MAX_POLL_TIME_MS = 10 * 60 * 1000
|
||||
let SESSION_CONTINUATION_STABILITY_MS = 5000
|
||||
|
||||
export function getTimingConfig() {
|
||||
return {
|
||||
POLL_INTERVAL_MS,
|
||||
MIN_STABILITY_TIME_MS,
|
||||
STABILITY_POLLS_REQUIRED,
|
||||
WAIT_FOR_SESSION_INTERVAL_MS,
|
||||
WAIT_FOR_SESSION_TIMEOUT_MS,
|
||||
MAX_POLL_TIME_MS,
|
||||
SESSION_CONTINUATION_STABILITY_MS,
|
||||
}
|
||||
}
|
||||
|
||||
export function __resetTimingConfig(): void {
|
||||
POLL_INTERVAL_MS = 500
|
||||
MIN_STABILITY_TIME_MS = 10000
|
||||
STABILITY_POLLS_REQUIRED = 3
|
||||
WAIT_FOR_SESSION_INTERVAL_MS = 100
|
||||
WAIT_FOR_SESSION_TIMEOUT_MS = 30000
|
||||
MAX_POLL_TIME_MS = 10 * 60 * 1000
|
||||
SESSION_CONTINUATION_STABILITY_MS = 5000
|
||||
}
|
||||
|
||||
export function __setTimingConfig(overrides: Partial<ReturnType<typeof getTimingConfig>>): void {
|
||||
if (overrides.POLL_INTERVAL_MS !== undefined) POLL_INTERVAL_MS = overrides.POLL_INTERVAL_MS
|
||||
if (overrides.MIN_STABILITY_TIME_MS !== undefined) MIN_STABILITY_TIME_MS = overrides.MIN_STABILITY_TIME_MS
|
||||
if (overrides.STABILITY_POLLS_REQUIRED !== undefined) STABILITY_POLLS_REQUIRED = overrides.STABILITY_POLLS_REQUIRED
|
||||
if (overrides.WAIT_FOR_SESSION_INTERVAL_MS !== undefined) WAIT_FOR_SESSION_INTERVAL_MS = overrides.WAIT_FOR_SESSION_INTERVAL_MS
|
||||
if (overrides.WAIT_FOR_SESSION_TIMEOUT_MS !== undefined) WAIT_FOR_SESSION_TIMEOUT_MS = overrides.WAIT_FOR_SESSION_TIMEOUT_MS
|
||||
if (overrides.MAX_POLL_TIME_MS !== undefined) MAX_POLL_TIME_MS = overrides.MAX_POLL_TIME_MS
|
||||
if (overrides.SESSION_CONTINUATION_STABILITY_MS !== undefined) SESSION_CONTINUATION_STABILITY_MS = overrides.SESSION_CONTINUATION_STABILITY_MS
|
||||
}
|
||||
@@ -1,17 +1,35 @@
|
||||
import { describe, test, expect, beforeEach } from "bun:test"
|
||||
import { DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS, CATEGORY_DESCRIPTIONS } from "./constants"
|
||||
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test"
|
||||
import { DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS, CATEGORY_DESCRIPTIONS, isPlanAgent, PLAN_AGENT_NAMES } from "./constants"
|
||||
import { resolveCategoryConfig } from "./tools"
|
||||
import type { CategoryConfig } from "../../config/schema"
|
||||
import { __resetModelCache } from "../../shared/model-availability"
|
||||
import { clearSkillCache } from "../../features/opencode-skill-loader/skill-content"
|
||||
import { __setTimingConfig, __resetTimingConfig } from "./timing"
|
||||
import * as connectedProvidersCache from "../../shared/connected-providers-cache"
|
||||
|
||||
// Test constants - systemDefaultModel is required by resolveCategoryConfig
|
||||
const SYSTEM_DEFAULT_MODEL = "anthropic/claude-sonnet-4-5"
|
||||
|
||||
describe("sisyphus-task", () => {
|
||||
let cacheSpy: ReturnType<typeof spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
__resetModelCache()
|
||||
clearSkillCache()
|
||||
__setTimingConfig({
|
||||
POLL_INTERVAL_MS: 10,
|
||||
MIN_STABILITY_TIME_MS: 50,
|
||||
STABILITY_POLLS_REQUIRED: 1,
|
||||
WAIT_FOR_SESSION_INTERVAL_MS: 10,
|
||||
WAIT_FOR_SESSION_TIMEOUT_MS: 1000,
|
||||
MAX_POLL_TIME_MS: 2000,
|
||||
SESSION_CONTINUATION_STABILITY_MS: 50,
|
||||
})
|
||||
cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["anthropic", "google", "openai"])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
__resetTimingConfig()
|
||||
cacheSpy?.mockRestore()
|
||||
})
|
||||
|
||||
describe("DEFAULT_CATEGORIES", () => {
|
||||
@@ -77,12 +95,93 @@ describe("sisyphus-task", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("isPlanAgent", () => {
|
||||
test("returns true for 'plan'", () => {
|
||||
// #given / #when
|
||||
const result = isPlanAgent("plan")
|
||||
|
||||
// #then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for 'prometheus'", () => {
|
||||
// #given / #when
|
||||
const result = isPlanAgent("prometheus")
|
||||
|
||||
// #then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for 'planner'", () => {
|
||||
// #given / #when
|
||||
const result = isPlanAgent("planner")
|
||||
|
||||
// #then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for case-insensitive match 'PLAN'", () => {
|
||||
// #given / #when
|
||||
const result = isPlanAgent("PLAN")
|
||||
|
||||
// #then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for case-insensitive match 'Prometheus'", () => {
|
||||
// #given / #when
|
||||
const result = isPlanAgent("Prometheus")
|
||||
|
||||
// #then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false for 'oracle'", () => {
|
||||
// #given / #when
|
||||
const result = isPlanAgent("oracle")
|
||||
|
||||
// #then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for 'explore'", () => {
|
||||
// #given / #when
|
||||
const result = isPlanAgent("explore")
|
||||
|
||||
// #then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for undefined", () => {
|
||||
// #given / #when
|
||||
const result = isPlanAgent(undefined)
|
||||
|
||||
// #then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for empty string", () => {
|
||||
// #given / #when
|
||||
const result = isPlanAgent("")
|
||||
|
||||
// #then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("PLAN_AGENT_NAMES contains expected values", () => {
|
||||
// #given / #when / #then
|
||||
expect(PLAN_AGENT_NAMES).toContain("plan")
|
||||
expect(PLAN_AGENT_NAMES).toContain("prometheus")
|
||||
expect(PLAN_AGENT_NAMES).toContain("planner")
|
||||
})
|
||||
})
|
||||
|
||||
describe("category delegation config validation", () => {
|
||||
test("returns error when systemDefaultModel is not configured", async () => {
|
||||
test("proceeds without error when systemDefaultModel is undefined", async () => {
|
||||
// #given a mock client with no model in config
|
||||
const { createDelegateTask } = require("./tools")
|
||||
|
||||
const mockManager = { launch: async () => ({}) }
|
||||
const mockManager = { launch: async () => ({ id: "task-123" }) }
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({}) }, // No model configured
|
||||
@@ -111,14 +210,64 @@ describe("sisyphus-task", () => {
|
||||
description: "Test task",
|
||||
prompt: "Do something",
|
||||
category: "ultrabrain",
|
||||
run_in_background: false,
|
||||
load_skills: ["git-master"],
|
||||
run_in_background: true,
|
||||
load_skills: [],
|
||||
},
|
||||
toolContext
|
||||
)
|
||||
|
||||
// #then returns descriptive error message
|
||||
expect(result).toContain("oh-my-opencode requires a default model")
|
||||
// #then proceeds without error - uses fallback chain
|
||||
expect(result).not.toContain("oh-my-opencode requires a default model")
|
||||
})
|
||||
|
||||
test("returns clear error when no model can be resolved", async () => {
|
||||
// #given - custom category with no model, no systemDefaultModel, no available models
|
||||
const { createDelegateTask } = require("./tools")
|
||||
|
||||
const mockManager = { launch: async () => ({ id: "task-123" }) }
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({}) }, // No model configured
|
||||
model: { list: async () => [] }, // No available models
|
||||
session: {
|
||||
create: async () => ({ data: { id: "test-session" } }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
|
||||
// Custom category with no model defined
|
||||
const tool = createDelegateTask({
|
||||
manager: mockManager,
|
||||
client: mockClient,
|
||||
userCategories: {
|
||||
"custom-no-model": { temperature: 0.5 }, // No model field
|
||||
},
|
||||
})
|
||||
|
||||
const toolContext = {
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
agent: "sisyphus",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
// #when delegating with a custom category that has no model
|
||||
const result = await tool.execute(
|
||||
{
|
||||
description: "Test task",
|
||||
prompt: "Do something",
|
||||
category: "custom-no-model",
|
||||
run_in_background: true,
|
||||
load_skills: [],
|
||||
},
|
||||
toolContext
|
||||
)
|
||||
|
||||
// #then returns clear error message with configuration guidance
|
||||
expect(result).toContain("Model not configured")
|
||||
expect(result).toContain("custom-no-model")
|
||||
expect(result).toContain("Configure in one of")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -452,12 +601,12 @@ describe("sisyphus-task", () => {
|
||||
toolContext
|
||||
)
|
||||
|
||||
// #then - variant MUST be "max" from DEFAULT_CATEGORIES
|
||||
// #then - variant MUST be "max" from DEFAULT_CATEGORIES (passed as separate field)
|
||||
expect(promptBody.model).toEqual({
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-opus-4-5",
|
||||
variant: "max",
|
||||
})
|
||||
expect(promptBody.variant).toBe("max")
|
||||
}, { timeout: 20000 })
|
||||
})
|
||||
|
||||
@@ -1481,6 +1630,87 @@ describe("sisyphus-task", () => {
|
||||
expect(result).toContain(categoryPromptAppend)
|
||||
expect(result).toContain("\n\n")
|
||||
})
|
||||
|
||||
test("prepends plan agent system prompt when agentName is 'plan'", () => {
|
||||
// #given
|
||||
const { buildSystemContent } = require("./tools")
|
||||
const { PLAN_AGENT_SYSTEM_PREPEND } = require("./constants")
|
||||
|
||||
// #when
|
||||
const result = buildSystemContent({ agentName: "plan" })
|
||||
|
||||
// #then
|
||||
expect(result).toContain("<system>")
|
||||
expect(result).toContain("MANDATORY CONTEXT GATHERING PROTOCOL")
|
||||
expect(result).toBe(PLAN_AGENT_SYSTEM_PREPEND)
|
||||
})
|
||||
|
||||
test("prepends plan agent system prompt when agentName is 'prometheus'", () => {
|
||||
// #given
|
||||
const { buildSystemContent } = require("./tools")
|
||||
const { PLAN_AGENT_SYSTEM_PREPEND } = require("./constants")
|
||||
|
||||
// #when
|
||||
const result = buildSystemContent({ agentName: "prometheus" })
|
||||
|
||||
// #then
|
||||
expect(result).toContain("<system>")
|
||||
expect(result).toBe(PLAN_AGENT_SYSTEM_PREPEND)
|
||||
})
|
||||
|
||||
test("prepends plan agent system prompt when agentName is 'Prometheus' (case insensitive)", () => {
|
||||
// #given
|
||||
const { buildSystemContent } = require("./tools")
|
||||
const { PLAN_AGENT_SYSTEM_PREPEND } = require("./constants")
|
||||
|
||||
// #when
|
||||
const result = buildSystemContent({ agentName: "Prometheus" })
|
||||
|
||||
// #then
|
||||
expect(result).toContain("<system>")
|
||||
expect(result).toBe(PLAN_AGENT_SYSTEM_PREPEND)
|
||||
})
|
||||
|
||||
test("combines plan agent prepend with skill content", () => {
|
||||
// #given
|
||||
const { buildSystemContent } = require("./tools")
|
||||
const { PLAN_AGENT_SYSTEM_PREPEND } = require("./constants")
|
||||
const skillContent = "You are a planning expert"
|
||||
|
||||
// #when
|
||||
const result = buildSystemContent({ skillContent, agentName: "plan" })
|
||||
|
||||
// #then
|
||||
expect(result).toContain(PLAN_AGENT_SYSTEM_PREPEND)
|
||||
expect(result).toContain(skillContent)
|
||||
expect(result!.indexOf(PLAN_AGENT_SYSTEM_PREPEND)).toBeLessThan(result!.indexOf(skillContent))
|
||||
})
|
||||
|
||||
test("does not prepend plan agent prompt for non-plan agents", () => {
|
||||
// #given
|
||||
const { buildSystemContent } = require("./tools")
|
||||
const skillContent = "You are an expert"
|
||||
|
||||
// #when
|
||||
const result = buildSystemContent({ skillContent, agentName: "oracle" })
|
||||
|
||||
// #then
|
||||
expect(result).toBe(skillContent)
|
||||
expect(result).not.toContain("<system>")
|
||||
})
|
||||
|
||||
test("does not prepend plan agent prompt when agentName is undefined", () => {
|
||||
// #given
|
||||
const { buildSystemContent } = require("./tools")
|
||||
const skillContent = "You are an expert"
|
||||
|
||||
// #when
|
||||
const result = buildSystemContent({ skillContent, agentName: undefined })
|
||||
|
||||
// #then
|
||||
expect(result).toBe(skillContent)
|
||||
expect(result).not.toContain("<system>")
|
||||
})
|
||||
})
|
||||
|
||||
describe("modelInfo detection via resolveCategoryConfig", () => {
|
||||
@@ -1662,4 +1892,250 @@ describe("sisyphus-task", () => {
|
||||
expect(resolved!.model).toBe(systemDefaultModel)
|
||||
})
|
||||
})
|
||||
|
||||
describe("prometheus self-delegation block", () => {
|
||||
test("prometheus cannot delegate to prometheus - returns error with guidance", async () => {
|
||||
// #given - current agent is prometheus
|
||||
const { createDelegateTask } = require("./tools")
|
||||
|
||||
const mockManager = { launch: async () => ({}) }
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [{ name: "prometheus", mode: "subagent" }] }) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "test-session" } }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
messages: async () => ({ data: [] }),
|
||||
status: async () => ({ data: {} }),
|
||||
},
|
||||
}
|
||||
|
||||
const tool = createDelegateTask({
|
||||
manager: mockManager,
|
||||
client: mockClient,
|
||||
})
|
||||
|
||||
const toolContext = {
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
agent: "prometheus",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
// #when - prometheus tries to delegate to prometheus
|
||||
const result = await tool.execute(
|
||||
{
|
||||
description: "Test self-delegation block",
|
||||
prompt: "Create a plan",
|
||||
subagent_type: "prometheus",
|
||||
run_in_background: false,
|
||||
load_skills: [],
|
||||
},
|
||||
toolContext
|
||||
)
|
||||
|
||||
// #then - should return error telling prometheus to create plan directly
|
||||
expect(result).toContain("prometheus")
|
||||
expect(result).toContain("directly")
|
||||
})
|
||||
|
||||
test("non-prometheus agent CAN delegate to prometheus - proceeds normally", async () => {
|
||||
// #given - current agent is sisyphus
|
||||
const { createDelegateTask } = require("./tools")
|
||||
|
||||
const mockManager = { launch: async () => ({}) }
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [{ name: "prometheus", mode: "subagent" }] }) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "ses_prometheus_allowed" } }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
messages: async () => ({
|
||||
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Plan created successfully" }] }]
|
||||
}),
|
||||
status: async () => ({ data: { "ses_prometheus_allowed": { type: "idle" } } }),
|
||||
},
|
||||
}
|
||||
|
||||
const tool = createDelegateTask({
|
||||
manager: mockManager,
|
||||
client: mockClient,
|
||||
})
|
||||
|
||||
const toolContext = {
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
agent: "sisyphus",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
// #when - sisyphus delegates to prometheus
|
||||
const result = await tool.execute(
|
||||
{
|
||||
description: "Test prometheus delegation from non-prometheus agent",
|
||||
prompt: "Create a plan",
|
||||
subagent_type: "prometheus",
|
||||
run_in_background: false,
|
||||
load_skills: [],
|
||||
},
|
||||
toolContext
|
||||
)
|
||||
|
||||
// #then - should proceed normally
|
||||
expect(result).not.toContain("Cannot delegate")
|
||||
expect(result).toContain("Plan created successfully")
|
||||
}, { timeout: 20000 })
|
||||
|
||||
test("case-insensitive: Prometheus (capitalized) cannot delegate to prometheus", async () => {
|
||||
// #given - current agent is Prometheus (capitalized)
|
||||
const { createDelegateTask } = require("./tools")
|
||||
|
||||
const mockManager = { launch: async () => ({}) }
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [{ name: "prometheus", mode: "subagent" }] }) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "test-session" } }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
messages: async () => ({ data: [] }),
|
||||
status: async () => ({ data: {} }),
|
||||
},
|
||||
}
|
||||
|
||||
const tool = createDelegateTask({
|
||||
manager: mockManager,
|
||||
client: mockClient,
|
||||
})
|
||||
|
||||
const toolContext = {
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
agent: "Prometheus",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
// #when - Prometheus tries to delegate to prometheus
|
||||
const result = await tool.execute(
|
||||
{
|
||||
description: "Test case-insensitive block",
|
||||
prompt: "Create a plan",
|
||||
subagent_type: "prometheus",
|
||||
run_in_background: false,
|
||||
load_skills: [],
|
||||
},
|
||||
toolContext
|
||||
)
|
||||
|
||||
// #then - should still return error
|
||||
expect(result).toContain("prometheus")
|
||||
expect(result).toContain("directly")
|
||||
})
|
||||
})
|
||||
|
||||
describe("prometheus subagent delegate_task permission", () => {
|
||||
test("prometheus subagent should have delegate_task permission enabled", async () => {
|
||||
// #given - sisyphus delegates to prometheus
|
||||
const { createDelegateTask } = require("./tools")
|
||||
let promptBody: any
|
||||
|
||||
const mockManager = { launch: async () => ({}) }
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [{ name: "prometheus", mode: "subagent" }] }) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "ses_prometheus_delegate" } }),
|
||||
prompt: async (input: any) => {
|
||||
promptBody = input.body
|
||||
return { data: {} }
|
||||
},
|
||||
messages: async () => ({
|
||||
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Plan created" }] }]
|
||||
}),
|
||||
status: async () => ({ data: { "ses_prometheus_delegate": { type: "idle" } } }),
|
||||
},
|
||||
}
|
||||
|
||||
const tool = createDelegateTask({
|
||||
manager: mockManager,
|
||||
client: mockClient,
|
||||
})
|
||||
|
||||
const toolContext = {
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
agent: "sisyphus",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
// #when - sisyphus delegates to prometheus
|
||||
await tool.execute(
|
||||
{
|
||||
description: "Test prometheus delegate_task permission",
|
||||
prompt: "Create a plan",
|
||||
subagent_type: "prometheus",
|
||||
run_in_background: false,
|
||||
load_skills: [],
|
||||
},
|
||||
toolContext
|
||||
)
|
||||
|
||||
// #then - prometheus should have delegate_task permission
|
||||
expect(promptBody.tools.delegate_task).toBe(true)
|
||||
}, { timeout: 20000 })
|
||||
|
||||
test("non-prometheus subagent should NOT have delegate_task permission", async () => {
|
||||
// #given - sisyphus delegates to oracle (non-prometheus)
|
||||
const { createDelegateTask } = require("./tools")
|
||||
let promptBody: any
|
||||
|
||||
const mockManager = { launch: async () => ({}) }
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [{ name: "oracle", mode: "subagent" }] }) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "ses_oracle_no_delegate" } }),
|
||||
prompt: async (input: any) => {
|
||||
promptBody = input.body
|
||||
return { data: {} }
|
||||
},
|
||||
messages: async () => ({
|
||||
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Consultation done" }] }]
|
||||
}),
|
||||
status: async () => ({ data: { "ses_oracle_no_delegate": { type: "idle" } } }),
|
||||
},
|
||||
}
|
||||
|
||||
const tool = createDelegateTask({
|
||||
manager: mockManager,
|
||||
client: mockClient,
|
||||
})
|
||||
|
||||
const toolContext = {
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
agent: "sisyphus",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
// #when - sisyphus delegates to oracle
|
||||
await tool.execute(
|
||||
{
|
||||
description: "Test oracle no delegate_task permission",
|
||||
prompt: "Consult on architecture",
|
||||
subagent_type: "oracle",
|
||||
run_in_background: false,
|
||||
load_skills: [],
|
||||
},
|
||||
toolContext
|
||||
)
|
||||
|
||||
// #then - oracle should NOT have delegate_task permission
|
||||
expect(promptBody.tools.delegate_task).toBe(false)
|
||||
}, { timeout: 20000 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,8 @@ import { join } from "node:path"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import type { DelegateTaskArgs } from "./types"
|
||||
import type { CategoryConfig, CategoriesConfig, GitMasterConfig, BrowserAutomationProvider } from "../../config/schema"
|
||||
import { DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS, CATEGORY_DESCRIPTIONS } from "./constants"
|
||||
import { DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS, CATEGORY_DESCRIPTIONS, PLAN_AGENT_SYSTEM_PREPEND, isPlanAgent } from "./constants"
|
||||
import { getTimingConfig } from "./timing"
|
||||
import { findNearestMessageWithFields, findFirstMessageWithAgent, MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
import { resolveMultipleSkillsAsync } from "../../features/opencode-skill-loader/skill-content"
|
||||
import { discoverSkills } from "../../features/opencode-skill-loader"
|
||||
@@ -115,9 +116,9 @@ export function resolveCategoryConfig(
|
||||
options: {
|
||||
userCategories?: CategoriesConfig
|
||||
inheritedModel?: string
|
||||
systemDefaultModel: string
|
||||
systemDefaultModel?: string
|
||||
}
|
||||
): { config: CategoryConfig; promptAppend: string; model: string } | null {
|
||||
): { config: CategoryConfig; promptAppend: string; model: string | undefined } | null {
|
||||
const { userCategories, inheritedModel, systemDefaultModel } = options
|
||||
const defaultConfig = DEFAULT_CATEGORIES[categoryName]
|
||||
const userConfig = userCategories?.[categoryName]
|
||||
@@ -171,20 +172,33 @@ export interface DelegateTaskToolOptions {
|
||||
export interface BuildSystemContentInput {
|
||||
skillContent?: string
|
||||
categoryPromptAppend?: string
|
||||
agentName?: string
|
||||
}
|
||||
|
||||
export function buildSystemContent(input: BuildSystemContentInput): string | undefined {
|
||||
const { skillContent, categoryPromptAppend } = input
|
||||
const { skillContent, categoryPromptAppend, agentName } = input
|
||||
|
||||
if (!skillContent && !categoryPromptAppend) {
|
||||
const planAgentPrepend = isPlanAgent(agentName) ? PLAN_AGENT_SYSTEM_PREPEND : ""
|
||||
|
||||
if (!skillContent && !categoryPromptAppend && !planAgentPrepend) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (skillContent && categoryPromptAppend) {
|
||||
return `${skillContent}\n\n${categoryPromptAppend}`
|
||||
const parts: string[] = []
|
||||
|
||||
if (planAgentPrepend) {
|
||||
parts.push(planAgentPrepend)
|
||||
}
|
||||
|
||||
return skillContent || categoryPromptAppend
|
||||
if (skillContent) {
|
||||
parts.push(skillContent)
|
||||
}
|
||||
|
||||
if (categoryPromptAppend) {
|
||||
parts.push(categoryPromptAppend)
|
||||
}
|
||||
|
||||
return parts.join("\n\n") || undefined
|
||||
}
|
||||
|
||||
export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefinition {
|
||||
@@ -382,6 +396,7 @@ Use \`background_output\` with task_id="${task.id}" to check progress.`
|
||||
task: false,
|
||||
delegate_task: false,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
},
|
||||
parts: [{ type: "text", text: args.prompt }],
|
||||
},
|
||||
@@ -395,9 +410,10 @@ Use \`background_output\` with task_id="${task.id}" to check progress.`
|
||||
}
|
||||
|
||||
// Wait for message stability after prompt completes
|
||||
const POLL_INTERVAL_MS = 500
|
||||
const MIN_STABILITY_TIME_MS = 5000
|
||||
const STABILITY_POLLS_REQUIRED = 3
|
||||
const timing = getTimingConfig()
|
||||
const POLL_INTERVAL_MS = timing.POLL_INTERVAL_MS
|
||||
const MIN_STABILITY_TIME_MS = timing.SESSION_CONTINUATION_STABILITY_MS
|
||||
const STABILITY_POLLS_REQUIRED = timing.STABILITY_POLLS_REQUIRED
|
||||
const pollStart = Date.now()
|
||||
let lastMsgCount = 0
|
||||
let stablePolls = 0
|
||||
@@ -497,17 +513,6 @@ To continue this session: session_id="${args.session_id}"`
|
||||
let modelInfo: ModelFallbackInfo | undefined
|
||||
|
||||
if (args.category) {
|
||||
// Guard: require system default model for category delegation
|
||||
if (!systemDefaultModel) {
|
||||
const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null })
|
||||
return (
|
||||
'oh-my-opencode requires a default model.\n\n' +
|
||||
`Add this to ${paths.configJsonc}:\n\n` +
|
||||
' "model": "anthropic/claude-sonnet-4-5"\n\n' +
|
||||
'(Replace with your preferred provider/model)'
|
||||
)
|
||||
}
|
||||
|
||||
const connectedProviders = readConnectedProvidersCache()
|
||||
const availableModels = await fetchAvailableModels(client, {
|
||||
connectedProviders: connectedProviders ?? undefined
|
||||
@@ -523,55 +528,73 @@ To continue this session: session_id="${args.session_id}"`
|
||||
}
|
||||
|
||||
const requirement = CATEGORY_MODEL_REQUIREMENTS[args.category]
|
||||
let actualModel: string
|
||||
let actualModel: string | undefined
|
||||
|
||||
if (!requirement) {
|
||||
actualModel = resolved.model
|
||||
modelInfo = { model: actualModel, type: "system-default", source: "system-default" }
|
||||
if (actualModel) {
|
||||
modelInfo = { model: actualModel, type: "system-default", source: "system-default" }
|
||||
}
|
||||
} else {
|
||||
const { model: resolvedModel, source, variant: resolvedVariant } = resolveModelWithFallback({
|
||||
const resolution = resolveModelWithFallback({
|
||||
userModel: userCategories?.[args.category]?.model ?? sisyphusJuniorModel,
|
||||
fallbackChain: requirement.fallbackChain,
|
||||
availableModels,
|
||||
systemDefaultModel,
|
||||
})
|
||||
|
||||
actualModel = resolvedModel
|
||||
if (resolution) {
|
||||
const { model: resolvedModel, source, variant: resolvedVariant } = resolution
|
||||
actualModel = resolvedModel
|
||||
|
||||
if (!parseModelString(actualModel)) {
|
||||
return `Invalid model format "${actualModel}". Expected "provider/model" format (e.g., "anthropic/claude-sonnet-4-5").`
|
||||
if (!parseModelString(actualModel)) {
|
||||
return `Invalid model format "${actualModel}". Expected "provider/model" format (e.g., "anthropic/claude-sonnet-4-5").`
|
||||
}
|
||||
|
||||
let type: "user-defined" | "inherited" | "category-default" | "system-default"
|
||||
switch (source) {
|
||||
case "override":
|
||||
type = "user-defined"
|
||||
break
|
||||
case "provider-fallback":
|
||||
type = "category-default"
|
||||
break
|
||||
case "system-default":
|
||||
type = "system-default"
|
||||
break
|
||||
}
|
||||
|
||||
modelInfo = { model: actualModel, type, source }
|
||||
|
||||
const parsedModel = parseModelString(actualModel)
|
||||
const variantToUse = userCategories?.[args.category]?.variant ?? resolvedVariant
|
||||
categoryModel = parsedModel
|
||||
? (variantToUse ? { ...parsedModel, variant: variantToUse } : parsedModel)
|
||||
: undefined
|
||||
}
|
||||
|
||||
let type: "user-defined" | "inherited" | "category-default" | "system-default"
|
||||
switch (source) {
|
||||
case "override":
|
||||
type = "user-defined"
|
||||
break
|
||||
case "provider-fallback":
|
||||
type = "category-default"
|
||||
break
|
||||
case "system-default":
|
||||
type = "system-default"
|
||||
break
|
||||
}
|
||||
|
||||
modelInfo = { model: actualModel, type, source }
|
||||
|
||||
const parsedModel = parseModelString(actualModel)
|
||||
const variantToUse = userCategories?.[args.category]?.variant ?? resolvedVariant
|
||||
categoryModel = parsedModel
|
||||
? (variantToUse ? { ...parsedModel, variant: variantToUse } : parsedModel)
|
||||
: undefined
|
||||
}
|
||||
|
||||
agentToUse = SISYPHUS_JUNIOR_AGENT
|
||||
if (!categoryModel) {
|
||||
const parsedModel = parseModelString(actualModel)
|
||||
categoryModel = parsedModel ?? undefined
|
||||
}
|
||||
categoryPromptAppend = resolved.promptAppend || undefined
|
||||
if (!categoryModel && actualModel) {
|
||||
const parsedModel = parseModelString(actualModel)
|
||||
categoryModel = parsedModel ?? undefined
|
||||
}
|
||||
categoryPromptAppend = resolved.promptAppend || undefined
|
||||
|
||||
const isUnstableAgent = resolved.config.is_unstable_agent === true || actualModel.toLowerCase().includes("gemini")
|
||||
if (!categoryModel && !actualModel) {
|
||||
const categoryNames = Object.keys({ ...DEFAULT_CATEGORIES, ...userCategories })
|
||||
return `Model not configured for category "${args.category}".
|
||||
|
||||
Configure in one of:
|
||||
1. OpenCode: Set "model" in opencode.json
|
||||
2. Oh-My-OpenCode: Set category model in oh-my-opencode.json
|
||||
3. Provider: Connect a provider with available models
|
||||
|
||||
Current category: ${args.category}
|
||||
Available categories: ${categoryNames.join(", ")}`
|
||||
}
|
||||
|
||||
const isUnstableAgent = resolved.config.is_unstable_agent === true || (actualModel?.toLowerCase().includes("gemini") ?? false)
|
||||
// Handle both boolean false and string "false" due to potential serialization
|
||||
const isRunInBackgroundExplicitlyFalse = args.run_in_background === false || args.run_in_background === "false" as unknown as boolean
|
||||
|
||||
@@ -586,7 +609,7 @@ To continue this session: session_id="${args.session_id}"`
|
||||
})
|
||||
|
||||
if (isUnstableAgent && isRunInBackgroundExplicitlyFalse) {
|
||||
const systemContent = buildSystemContent({ skillContent, categoryPromptAppend })
|
||||
const systemContent = buildSystemContent({ skillContent, categoryPromptAppend, agentName: agentToUse })
|
||||
|
||||
try {
|
||||
const task = await manager.launch({
|
||||
@@ -641,10 +664,11 @@ To continue this session: session_id="${args.session_id}"`
|
||||
const startTime = new Date()
|
||||
|
||||
// Poll for completion (same logic as sync mode)
|
||||
const POLL_INTERVAL_MS = 500
|
||||
const MAX_POLL_TIME_MS = 10 * 60 * 1000
|
||||
const MIN_STABILITY_TIME_MS = 10000
|
||||
const STABILITY_POLLS_REQUIRED = 3
|
||||
const timingCfg = getTimingConfig()
|
||||
const POLL_INTERVAL_MS = timingCfg.POLL_INTERVAL_MS
|
||||
const MAX_POLL_TIME_MS = timingCfg.MAX_POLL_TIME_MS
|
||||
const MIN_STABILITY_TIME_MS = timingCfg.MIN_STABILITY_TIME_MS
|
||||
const STABILITY_POLLS_REQUIRED = timingCfg.STABILITY_POLLS_REQUIRED
|
||||
const pollStart = Date.now()
|
||||
let lastMsgCount = 0
|
||||
let stablePolls = 0
|
||||
@@ -744,6 +768,12 @@ To continue this session: session_id="${sessionID}"`
|
||||
Sisyphus-Junior is spawned automatically when you specify a category. Pick the appropriate category for your task domain.`
|
||||
}
|
||||
|
||||
if (isPlanAgent(agentName) && isPlanAgent(parentAgent)) {
|
||||
return `You are prometheus. You cannot delegate to prometheus via delegate_task.
|
||||
|
||||
Create the work plan directly - that's your job as the planning agent.`
|
||||
}
|
||||
|
||||
agentToUse = agentName
|
||||
|
||||
// Validate agent exists and is callable (not a primary agent)
|
||||
@@ -778,7 +808,7 @@ Sisyphus-Junior is spawned automatically when you specify a category. Pick the a
|
||||
}
|
||||
}
|
||||
|
||||
const systemContent = buildSystemContent({ skillContent, categoryPromptAppend })
|
||||
const systemContent = buildSystemContent({ skillContent, categoryPromptAppend, agentName: agentToUse })
|
||||
|
||||
if (runInBackground) {
|
||||
try {
|
||||
@@ -843,7 +873,10 @@ To continue this session: session_id="${task.sessionID}"`
|
||||
body: {
|
||||
parentID: ctx.sessionID,
|
||||
title: `Task: ${args.description}`,
|
||||
},
|
||||
permission: [
|
||||
{ permission: "question", action: "deny" as const, pattern: "*" },
|
||||
],
|
||||
} as any,
|
||||
query: {
|
||||
directory: parentDirectory,
|
||||
},
|
||||
@@ -900,6 +933,7 @@ To continue this session: session_id="${task.sessionID}"`
|
||||
})
|
||||
|
||||
try {
|
||||
const allowDelegateTask = isPlanAgent(agentToUse)
|
||||
await client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
@@ -907,11 +941,13 @@ To continue this session: session_id="${task.sessionID}"`
|
||||
system: systemContent,
|
||||
tools: {
|
||||
task: false,
|
||||
delegate_task: false,
|
||||
delegate_task: allowDelegateTask,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
},
|
||||
parts: [{ type: "text", text: args.prompt }],
|
||||
...(categoryModel ? { model: categoryModel } : {}),
|
||||
...(categoryModel ? { model: { providerID: categoryModel.providerID, modelID: categoryModel.modelID } } : {}),
|
||||
...(categoryModel?.variant ? { variant: categoryModel.variant } : {}),
|
||||
},
|
||||
})
|
||||
} catch (promptError) {
|
||||
@@ -939,10 +975,11 @@ To continue this session: session_id="${task.sessionID}"`
|
||||
|
||||
// Poll for session completion with stability detection
|
||||
// The session may show as "idle" before messages appear, so we also check message stability
|
||||
const POLL_INTERVAL_MS = 500
|
||||
const MAX_POLL_TIME_MS = 10 * 60 * 1000
|
||||
const MIN_STABILITY_TIME_MS = 10000 // Minimum 10s before accepting completion
|
||||
const STABILITY_POLLS_REQUIRED = 3
|
||||
const syncTiming = getTimingConfig()
|
||||
const POLL_INTERVAL_MS = syncTiming.POLL_INTERVAL_MS
|
||||
const MAX_POLL_TIME_MS = syncTiming.MAX_POLL_TIME_MS
|
||||
const MIN_STABILITY_TIME_MS = syncTiming.MIN_STABILITY_TIME_MS
|
||||
const STABILITY_POLLS_REQUIRED = syncTiming.STABILITY_POLLS_REQUIRED
|
||||
const pollStart = Date.now()
|
||||
let lastMsgCount = 0
|
||||
let stablePolls = 0
|
||||
|
||||
@@ -102,7 +102,10 @@ If the requested information is not found, clearly state what is missing.`
|
||||
body: {
|
||||
parentID: toolContext.sessionID,
|
||||
title: `look_at: ${args.goal.substring(0, 50)}`,
|
||||
},
|
||||
permission: [
|
||||
{ permission: "question", action: "deny" as const, pattern: "*" },
|
||||
],
|
||||
} as any,
|
||||
query: {
|
||||
directory: parentDirectory,
|
||||
},
|
||||
@@ -110,6 +113,17 @@ If the requested information is not found, clearly state what is missing.`
|
||||
|
||||
if (createResult.error) {
|
||||
log(`[look_at] Session create error:`, createResult.error)
|
||||
const errorStr = String(createResult.error)
|
||||
if (errorStr.toLowerCase().includes("unauthorized")) {
|
||||
return `Error: Failed to create session (Unauthorized). This may be due to:
|
||||
1. OAuth token restrictions (e.g., Claude Code credentials are restricted to Claude Code only)
|
||||
2. Provider authentication issues
|
||||
3. Session permission inheritance problems
|
||||
|
||||
Try using a different provider or API key authentication.
|
||||
|
||||
Original error: ${createResult.error}`
|
||||
}
|
||||
return `Error: Failed to create session: ${createResult.error}`
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user