Compare commits
75 Commits
feat/nativ
...
v3.5.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c0354225c | ||
|
|
9ba933743a | ||
|
|
cb4a165c76 | ||
|
|
d3574a392f | ||
|
|
0ef682965f | ||
|
|
dd11d5df1b | ||
|
|
130aaaf910 | ||
|
|
7e6982c8d8 | ||
|
|
2a4009e692 | ||
|
|
2b7ef43619 | ||
|
|
5c9ef7bb1c | ||
|
|
67efe2d7af | ||
|
|
abfab1a78a | ||
|
|
24ea3627ad | ||
|
|
c2f22cd6e5 | ||
|
|
6a90182503 | ||
|
|
1509c897fc | ||
|
|
dd91a7d990 | ||
|
|
a9dd6d2ce8 | ||
|
|
33d290b346 | ||
|
|
7108d244d1 | ||
|
|
418e0e9f76 | ||
|
|
b963571642 | ||
|
|
18442a1637 | ||
|
|
d076187f0a | ||
|
|
8a5f61724d | ||
|
|
3f557e593c | ||
|
|
284fafad11 | ||
|
|
884a3addf8 | ||
|
|
c8172697d9 | ||
|
|
6dc8b7b875 | ||
|
|
361d9a82d7 | ||
|
|
d8b4dba963 | ||
|
|
7b89df01a3 | ||
|
|
dcb76f7efd | ||
|
|
7b62f0c68b | ||
|
|
2a7dfac50e | ||
|
|
2b4651e119 | ||
|
|
37d3086658 | ||
|
|
e7dc3721df | ||
|
|
e995443120 | ||
|
|
3a690965fd | ||
|
|
74d2ae1023 | ||
|
|
a0c9381672 | ||
|
|
65a06aa2b7 | ||
|
|
754e6ee064 | ||
|
|
affefee12f | ||
|
|
90463bafd2 | ||
|
|
073a074f8d | ||
|
|
cdda08cdb0 | ||
|
|
a8d26e3f74 | ||
|
|
8401f0a918 | ||
|
|
32470f5ca0 | ||
|
|
c3793f779b | ||
|
|
3de05f6442 | ||
|
|
8514906c3d | ||
|
|
f20e1aa0d0 | ||
|
|
936b51de79 | ||
|
|
38a4bbc75f | ||
|
|
7186c368b9 | ||
|
|
121a3c45c5 | ||
|
|
072b30593e | ||
|
|
dd9eeaa6d6 | ||
|
|
3fa543e851 | ||
|
|
9f52e48e8f | ||
|
|
26ae666bc3 | ||
|
|
422db236fe | ||
|
|
b7c32e8f50 | ||
|
|
c24c4a85b4 | ||
|
|
f3ff32fd18 | ||
|
|
daf011c616 | ||
|
|
c8bc267127 | ||
|
|
c41b38990c | ||
|
|
a4a5502e61 | ||
|
|
1511886c0c |
31
.github/workflows/ci.yml
vendored
31
.github/workflows/ci.yml
vendored
@@ -52,12 +52,31 @@ jobs:
|
||||
bun test src/hooks/atlas
|
||||
bun test src/hooks/compaction-context-injector
|
||||
bun test src/features/tmux-subagent
|
||||
bun test src/cli/doctor/formatter.test.ts
|
||||
bun test src/cli/doctor/format-default.test.ts
|
||||
bun test src/tools/call-omo-agent/sync-executor.test.ts
|
||||
bun test src/tools/call-omo-agent/session-creator.test.ts
|
||||
bun test src/features/opencode-skill-loader/loader.test.ts
|
||||
|
||||
- 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 \
|
||||
# Enumerate subdirectories/files explicitly to EXCLUDE mock-heavy files
|
||||
# that were already run in isolation above.
|
||||
# Excluded from src/cli: doctor/formatter.test.ts, doctor/format-default.test.ts
|
||||
# Excluded from src/tools: call-omo-agent/sync-executor.test.ts, call-omo-agent/session-creator.test.ts
|
||||
bun test bin script src/config src/mcp src/index.test.ts \
|
||||
src/agents src/shared \
|
||||
src/cli/run src/cli/config-manager src/cli/mcp-oauth \
|
||||
src/cli/index.test.ts src/cli/install.test.ts src/cli/model-fallback.test.ts \
|
||||
src/cli/config-manager.test.ts \
|
||||
src/cli/doctor/runner.test.ts src/cli/doctor/checks \
|
||||
src/tools/ast-grep src/tools/background-task src/tools/delegate-task \
|
||||
src/tools/glob src/tools/grep src/tools/interactive-bash \
|
||||
src/tools/look-at src/tools/lsp src/tools/session-manager \
|
||||
src/tools/skill src/tools/skill-mcp src/tools/slashcommand src/tools/task \
|
||||
src/tools/call-omo-agent/background-agent-executor.test.ts \
|
||||
src/tools/call-omo-agent/background-executor.test.ts \
|
||||
src/tools/call-omo-agent/subagent-session-creator.test.ts \
|
||||
src/hooks/anthropic-context-window-limit-recovery \
|
||||
src/hooks/claude-code-compatibility \
|
||||
src/hooks/context-injection \
|
||||
@@ -70,7 +89,11 @@ jobs:
|
||||
src/features/builtin-skills \
|
||||
src/features/claude-code-session-state \
|
||||
src/features/hook-message-injector \
|
||||
src/features/opencode-skill-loader \
|
||||
src/features/opencode-skill-loader/config-source-discovery.test.ts \
|
||||
src/features/opencode-skill-loader/merger.test.ts \
|
||||
src/features/opencode-skill-loader/skill-content.test.ts \
|
||||
src/features/opencode-skill-loader/blocking.test.ts \
|
||||
src/features/opencode-skill-loader/async-loader.test.ts \
|
||||
src/features/skill-mcp-manager
|
||||
|
||||
typecheck:
|
||||
|
||||
32
.github/workflows/publish.yml
vendored
32
.github/workflows/publish.yml
vendored
@@ -51,13 +51,33 @@ jobs:
|
||||
# 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
|
||||
bun test src/cli/doctor/formatter.test.ts
|
||||
bun test src/cli/doctor/format-default.test.ts
|
||||
bun test src/tools/call-omo-agent/sync-executor.test.ts
|
||||
bun test src/tools/call-omo-agent/session-creator.test.ts
|
||||
bun test src/features/opencode-skill-loader/loader.test.ts
|
||||
|
||||
- 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 \
|
||||
# Enumerate subdirectories/files explicitly to EXCLUDE mock-heavy files
|
||||
# that were already run in isolation above.
|
||||
# Excluded from src/cli: doctor/formatter.test.ts, doctor/format-default.test.ts
|
||||
# Excluded from src/tools: call-omo-agent/sync-executor.test.ts, call-omo-agent/session-creator.test.ts
|
||||
bun test bin script src/config src/mcp src/index.test.ts \
|
||||
src/agents src/shared \
|
||||
src/cli/run src/cli/config-manager src/cli/mcp-oauth \
|
||||
src/cli/index.test.ts src/cli/install.test.ts src/cli/model-fallback.test.ts \
|
||||
src/cli/config-manager.test.ts \
|
||||
src/cli/doctor/runner.test.ts src/cli/doctor/checks \
|
||||
src/tools/ast-grep src/tools/background-task src/tools/delegate-task \
|
||||
src/tools/glob src/tools/grep src/tools/interactive-bash \
|
||||
src/tools/look-at src/tools/lsp src/tools/session-manager \
|
||||
src/tools/skill src/tools/skill-mcp src/tools/slashcommand src/tools/task \
|
||||
src/tools/call-omo-agent/background-agent-executor.test.ts \
|
||||
src/tools/call-omo-agent/background-executor.test.ts \
|
||||
src/tools/call-omo-agent/subagent-session-creator.test.ts \
|
||||
src/hooks/anthropic-context-window-limit-recovery \
|
||||
src/hooks/claude-code-compatibility \
|
||||
src/hooks/context-injection \
|
||||
@@ -70,7 +90,11 @@ jobs:
|
||||
src/features/builtin-skills \
|
||||
src/features/claude-code-session-state \
|
||||
src/features/hook-message-injector \
|
||||
src/features/opencode-skill-loader \
|
||||
src/features/opencode-skill-loader/config-source-discovery.test.ts \
|
||||
src/features/opencode-skill-loader/merger.test.ts \
|
||||
src/features/opencode-skill-loader/skill-content.test.ts \
|
||||
src/features/opencode-skill-loader/blocking.test.ts \
|
||||
src/features/opencode-skill-loader/async-loader.test.ts \
|
||||
src/features/skill-mcp-manager
|
||||
|
||||
typecheck:
|
||||
|
||||
@@ -162,9 +162,6 @@
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -210,9 +207,6 @@
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -300,9 +294,6 @@
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
@@ -344,9 +335,6 @@
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -392,9 +380,6 @@
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -482,9 +467,6 @@
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
@@ -526,9 +508,6 @@
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -574,9 +553,6 @@
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -664,9 +640,6 @@
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
@@ -708,9 +681,6 @@
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -756,9 +726,6 @@
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -846,9 +813,6 @@
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
@@ -890,9 +854,6 @@
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -938,9 +899,6 @@
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -1028,9 +986,6 @@
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
@@ -1072,9 +1027,6 @@
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -1120,9 +1072,6 @@
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -1210,9 +1159,6 @@
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
@@ -1254,9 +1200,6 @@
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -1302,9 +1245,6 @@
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -1392,9 +1332,6 @@
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
@@ -1436,9 +1373,6 @@
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -1484,9 +1418,6 @@
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -1574,9 +1505,6 @@
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
@@ -1618,9 +1546,6 @@
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -1666,9 +1591,6 @@
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -1756,9 +1678,6 @@
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
@@ -1800,9 +1719,6 @@
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -1848,9 +1764,6 @@
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -1938,9 +1851,6 @@
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
@@ -1982,9 +1892,6 @@
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -2030,9 +1937,6 @@
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -2120,9 +2024,6 @@
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
@@ -2164,9 +2065,6 @@
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -2212,9 +2110,6 @@
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -2302,9 +2197,6 @@
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
@@ -2346,9 +2238,6 @@
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -2394,9 +2283,6 @@
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -2484,9 +2370,6 @@
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
@@ -2528,9 +2411,6 @@
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -2576,9 +2456,6 @@
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -2666,9 +2543,6 @@
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
@@ -2679,9 +2553,6 @@
|
||||
},
|
||||
"categories": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2745,9 +2616,6 @@
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -2788,9 +2656,6 @@
|
||||
},
|
||||
"plugins_override": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -3061,9 +2926,6 @@
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"allowed-tools": {
|
||||
@@ -3115,9 +2977,6 @@
|
||||
},
|
||||
"providerConcurrency": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
@@ -3125,9 +2984,6 @@
|
||||
},
|
||||
"modelConcurrency": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
@@ -3136,6 +2992,10 @@
|
||||
"staleTimeoutMs": {
|
||||
"type": "number",
|
||||
"minimum": 60000
|
||||
},
|
||||
"messageStalenessTimeoutMs": {
|
||||
"type": "number",
|
||||
"minimum": 60000
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
28
bun.lock
28
bun.lock
@@ -28,13 +28,13 @@
|
||||
"typescript": "^5.7.3",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"oh-my-opencode-darwin-arm64": "3.5.2",
|
||||
"oh-my-opencode-darwin-x64": "3.5.2",
|
||||
"oh-my-opencode-linux-arm64": "3.5.2",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.5.2",
|
||||
"oh-my-opencode-linux-x64": "3.5.2",
|
||||
"oh-my-opencode-linux-x64-musl": "3.5.2",
|
||||
"oh-my-opencode-windows-x64": "3.5.2",
|
||||
"oh-my-opencode-darwin-arm64": "3.5.5",
|
||||
"oh-my-opencode-darwin-x64": "3.5.5",
|
||||
"oh-my-opencode-linux-arm64": "3.5.5",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.5.5",
|
||||
"oh-my-opencode-linux-x64": "3.5.5",
|
||||
"oh-my-opencode-linux-x64-musl": "3.5.5",
|
||||
"oh-my-opencode-windows-x64": "3.5.5",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -226,19 +226,19 @@
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.5.2", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-oIS3lB2F9/N+3mF5wCKk6/EPVSz516XWN+mNdquSSeddw+xqMxGdhKY6K/XeYbHJzeN2Z8IOikNEJ6psR2/a8g=="],
|
||||
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.5.5", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-XtcCQ8/iVT6T1B58y0N1oMgOK4beTW8DW98b/ITnINb7b3hNSv5754Af/2Rx67BV0iE0ezC6uXaqz45C7ru1rw=="],
|
||||
|
||||
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.5.2", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-OAdXo4ZCCYO4kRWtnyz3tdmaGYPUB3WcXimXAxp+/sEZxAnh7n1RQkpLn6UxWX4AIAdRT9dfrOfRic6VoCYv2g=="],
|
||||
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.5.5", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-ReSDqU6jihh7lpGNmEt3REzc5bOcyfv3cMHitpecKq0wRrJoTBI+dgNPk90BLjHobGbhAm0TE8VZ9tqTkivnIQ=="],
|
||||
|
||||
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.5.2", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-5XXNMFhp1VsyrGNRBoXcOyoaUeVkbrWkBRPDGZfpiq+kRXH3aaSWdR5G7Pl/TadOQv9Bl8/8YaxsuHRTFT1aXw=="],
|
||||
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.5.5", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Zs/ETIxwcWBvw+jdlo8t+3+92oMMaXkFg1ZCuZrBRZOmtPFefdsH5/QEIe2TlNSjfoTwlA7cbpOD6oXgxRVrtg=="],
|
||||
|
||||
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.5.2", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-/woIpqvEI85MgJvEVnz4g5FBLeiQNK7srRsueIFPBmtTahh42HFleCDaIltOl/ndjsE5nCHacQVJHkC9W9/F3Q=="],
|
||||
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.5.5", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-m9r4OW1XhGtm/SvHM3kzpS4pEiI2eIh5Tj+j5hpMW3wu+AqE3F1XGUpu8RgvIpupFo8beimJWDYQujqokReQqg=="],
|
||||
|
||||
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.5.2", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-vTL2A+6zzGhi+m7sC8peLDq5OAp2dRR0UEb4RbZAOHtlEruF7qFEmcK3ccWxwc3+Z3G/ITfwn5VNa72ZS4pNTg=="],
|
||||
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.5.5", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-N6ysF5Pr2C1dyC5Dftzp05RJODgL+EYCWcOV59/UCV152cINlOhg80804o+6XTKV/taOAaboYaQwsBKiCs/BNQ=="],
|
||||
|
||||
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.5.2", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-bOAA55snLsK2QB00IkQy8le0Oqh/GJ7pxEHtm1oUezlQrW/nX5SS/hJ7dPHMmOd9FoiqnqyqWZxNkLmFoG463A=="],
|
||||
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.5.5", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-MOxW1FMTJT3Ze/U2fDedcZUYTFaA9PaKIiqtsBIHOSb+fFgdo51RIuUlKCELN/g9I9dYhw0yP2n9tBMBG6feSg=="],
|
||||
|
||||
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.5.2", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-fnHiAPYglw3unPckmQBoCT6+VqjSWCE3S3J551mRo0ZFrxuEP2ZKyHZeFMMOtKwDepCvmKgd1W040+KmuVUXOA=="],
|
||||
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.5.5", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-dWRtPyIdMFQIw1BwVO4PbGqoo0UWs7NES+YJC7BLGv0YnWN7Q2tatmOviSeSgMELeMsWSbDNisEB79jsfShXjA=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
|
||||
16
package.json
16
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "3.5.3",
|
||||
"version": "3.5.6",
|
||||
"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",
|
||||
@@ -74,13 +74,13 @@
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"oh-my-opencode-darwin-arm64": "3.5.3",
|
||||
"oh-my-opencode-darwin-x64": "3.5.3",
|
||||
"oh-my-opencode-linux-arm64": "3.5.3",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.5.3",
|
||||
"oh-my-opencode-linux-x64": "3.5.3",
|
||||
"oh-my-opencode-linux-x64-musl": "3.5.3",
|
||||
"oh-my-opencode-windows-x64": "3.5.3"
|
||||
"oh-my-opencode-darwin-arm64": "3.5.6",
|
||||
"oh-my-opencode-darwin-x64": "3.5.6",
|
||||
"oh-my-opencode-linux-arm64": "3.5.6",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.5.6",
|
||||
"oh-my-opencode-linux-x64": "3.5.6",
|
||||
"oh-my-opencode-linux-x64-musl": "3.5.6",
|
||||
"oh-my-opencode-windows-x64": "3.5.6"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@ast-grep/cli",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-arm64",
|
||||
"version": "3.5.3",
|
||||
"version": "3.5.6",
|
||||
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-x64",
|
||||
"version": "3.5.3",
|
||||
"version": "3.5.6",
|
||||
"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.5.3",
|
||||
"version": "3.5.6",
|
||||
"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.5.3",
|
||||
"version": "3.5.6",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64-musl",
|
||||
"version": "3.5.3",
|
||||
"version": "3.5.6",
|
||||
"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.5.3",
|
||||
"version": "3.5.6",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-windows-x64",
|
||||
"version": "3.5.3",
|
||||
"version": "3.5.6",
|
||||
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1471,6 +1471,54 @@
|
||||
"created_at": "2026-02-14T04:15:19Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1827
|
||||
},
|
||||
{
|
||||
"name": "morphaxl",
|
||||
"id": 57144942,
|
||||
"comment_id": 3872741516,
|
||||
"created_at": "2026-02-09T16:21:56Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1699
|
||||
},
|
||||
{
|
||||
"name": "morphaxl",
|
||||
"id": 57144942,
|
||||
"comment_id": 3872742242,
|
||||
"created_at": "2026-02-09T16:22:04Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1699
|
||||
},
|
||||
{
|
||||
"name": "liu-qingyuan",
|
||||
"id": 57737268,
|
||||
"comment_id": 3902402078,
|
||||
"created_at": "2026-02-14T19:39:58Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1844
|
||||
},
|
||||
{
|
||||
"name": "iyoda",
|
||||
"id": 31020,
|
||||
"comment_id": 3902426789,
|
||||
"created_at": "2026-02-14T19:58:19Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1845
|
||||
},
|
||||
{
|
||||
"name": "Decrabbityyy",
|
||||
"id": 99632363,
|
||||
"comment_id": 3904649522,
|
||||
"created_at": "2026-02-15T15:07:11Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1864
|
||||
},
|
||||
{
|
||||
"name": "dankochetov",
|
||||
"id": 33990502,
|
||||
"comment_id": 3905398332,
|
||||
"created_at": "2026-02-15T23:17:05Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1870
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -66,7 +66,7 @@ describe("PROMETHEUS_SYSTEM_PROMPT zero human intervention", () => {
|
||||
expect(lowerPrompt).toContain("preconditions")
|
||||
expect(lowerPrompt).toContain("failure indicators")
|
||||
expect(lowerPrompt).toContain("evidence")
|
||||
expect(lowerPrompt).toMatch(/negative scenario/)
|
||||
expect(prompt).toMatch(/negative/i)
|
||||
})
|
||||
|
||||
test("should require QA scenario adequacy in self-review checklist", () => {
|
||||
|
||||
@@ -129,7 +129,21 @@ Your ONLY valid output locations are \`.sisyphus/plans/*.md\` and \`.sisyphus/dr
|
||||
|
||||
Example: \`.sisyphus/plans/auth-refactor.md\`
|
||||
|
||||
### 5. SINGLE PLAN MANDATE (CRITICAL)
|
||||
### 5. MAXIMUM PARALLELISM PRINCIPLE (NON-NEGOTIABLE)
|
||||
|
||||
Your plans MUST maximize parallel execution. This is a core planning quality metric.
|
||||
|
||||
**Granularity Rule**: One task = one module/concern = 1-3 files.
|
||||
If a task touches 4+ files or 2+ unrelated concerns, SPLIT IT.
|
||||
|
||||
**Parallelism Target**: Aim for 5-8 tasks per wave.
|
||||
If any wave has fewer than 3 tasks (except the final integration), you under-split.
|
||||
|
||||
**Dependency Minimization**: Structure tasks so shared dependencies
|
||||
(types, interfaces, configs) are extracted as early Wave-1 tasks,
|
||||
unblocking maximum parallelism in subsequent waves.
|
||||
|
||||
### 6. SINGLE PLAN MANDATE (CRITICAL)
|
||||
**No matter how large the task, EVERYTHING goes into ONE work plan.**
|
||||
|
||||
**NEVER:**
|
||||
@@ -152,7 +166,7 @@ Example: \`.sisyphus/plans/auth-refactor.md\`
|
||||
|
||||
**The plan can have 50+ TODOs. That's OK. ONE PLAN.**
|
||||
|
||||
### 5.1 SINGLE ATOMIC WRITE (CRITICAL - Prevents Content Loss)
|
||||
### 6.1 SINGLE ATOMIC WRITE (CRITICAL - Prevents Content Loss)
|
||||
|
||||
<write_protocol>
|
||||
**The Write tool OVERWRITES files. It does NOT append.**
|
||||
@@ -188,7 +202,7 @@ Example: \`.sisyphus/plans/auth-refactor.md\`
|
||||
- [ ] File already exists with my content? → Use Edit to append, NOT Write
|
||||
</write_protocol>
|
||||
|
||||
### 6. DRAFT AS WORKING MEMORY (MANDATORY)
|
||||
### 7. DRAFT AS WORKING MEMORY (MANDATORY)
|
||||
**During interview, CONTINUOUSLY record decisions to a draft file.**
|
||||
|
||||
**Draft Location**: \`.sisyphus/drafts/{name}.md\`
|
||||
|
||||
@@ -70,108 +70,25 @@ Generate plan to: \`.sisyphus/plans/{name}.md\`
|
||||
|
||||
## Verification Strategy (MANDATORY)
|
||||
|
||||
> **UNIVERSAL RULE: ZERO HUMAN INTERVENTION**
|
||||
>
|
||||
> ALL tasks in this plan MUST be verifiable WITHOUT any human action.
|
||||
> This is NOT conditional — it applies to EVERY task, regardless of test strategy.
|
||||
>
|
||||
> **FORBIDDEN** — acceptance criteria that require:
|
||||
> - "User manually tests..." / "사용자가 직접 테스트..."
|
||||
> - "User visually confirms..." / "사용자가 눈으로 확인..."
|
||||
> - "User interacts with..." / "사용자가 직접 조작..."
|
||||
> - "Ask user to verify..." / "사용자에게 확인 요청..."
|
||||
> - ANY step where a human must perform an action
|
||||
>
|
||||
> **ALL verification is executed by the agent** using tools (Playwright, interactive_bash, curl, etc.). No exceptions.
|
||||
> **ZERO HUMAN INTERVENTION** — ALL verification is agent-executed. No exceptions.
|
||||
> Acceptance criteria requiring "user manually tests/confirms" are FORBIDDEN.
|
||||
|
||||
### Test Decision
|
||||
- **Infrastructure exists**: [YES/NO]
|
||||
- **Automated tests**: [TDD / Tests-after / None]
|
||||
- **Framework**: [bun test / vitest / jest / pytest / none]
|
||||
- **If TDD**: Each task follows RED (failing test) → GREEN (minimal impl) → REFACTOR
|
||||
|
||||
### If TDD Enabled
|
||||
### QA Policy
|
||||
Every task MUST include agent-executed QA scenarios (see TODO template below).
|
||||
Evidence saved to \`.sisyphus/evidence/task-{N}-{scenario-slug}.{ext}\`.
|
||||
|
||||
Each TODO follows RED-GREEN-REFACTOR:
|
||||
|
||||
**Task Structure:**
|
||||
1. **RED**: Write failing test first
|
||||
- Test file: \`[path].test.ts\`
|
||||
- Test command: \`bun test [file]\`
|
||||
- Expected: FAIL (test exists, implementation doesn't)
|
||||
2. **GREEN**: Implement minimum code to pass
|
||||
- Command: \`bun test [file]\`
|
||||
- Expected: PASS
|
||||
3. **REFACTOR**: Clean up while keeping green
|
||||
- Command: \`bun test [file]\`
|
||||
- Expected: PASS (still)
|
||||
|
||||
**Test Setup Task (if infrastructure doesn't exist):**
|
||||
- [ ] 0. Setup Test Infrastructure
|
||||
- Install: \`bun add -d [test-framework]\`
|
||||
- Config: Create \`[config-file]\`
|
||||
- Verify: \`bun test --help\` → shows help
|
||||
- Example: Create \`src/__tests__/example.test.ts\`
|
||||
- Verify: \`bun test\` → 1 test passes
|
||||
|
||||
### Agent-Executed QA Scenarios (MANDATORY — ALL tasks)
|
||||
|
||||
> Whether TDD is enabled or not, EVERY task MUST include Agent-Executed QA Scenarios.
|
||||
> - **With TDD**: QA scenarios complement unit tests at integration/E2E level
|
||||
> - **Without TDD**: QA scenarios are the PRIMARY verification method
|
||||
>
|
||||
> These describe how the executing agent DIRECTLY verifies the deliverable
|
||||
> by running it — opening browsers, executing commands, sending API requests.
|
||||
> The agent performs what a human tester would do, but automated via tools.
|
||||
|
||||
**Verification Tool by Deliverable Type:**
|
||||
|
||||
| Type | Tool | How Agent Verifies |
|
||||
|------|------|-------------------|
|
||||
| **Frontend/UI** | Playwright (playwright skill) | Navigate, interact, assert DOM, screenshot |
|
||||
| **TUI/CLI** | interactive_bash (tmux) | Run command, send keystrokes, validate output |
|
||||
| **API/Backend** | Bash (curl/httpie) | Send requests, parse responses, assert fields |
|
||||
| **Library/Module** | Bash (bun/node REPL) | Import, call functions, compare output |
|
||||
| **Config/Infra** | Bash (shell commands) | Apply config, run state checks, validate |
|
||||
|
||||
**Each Scenario MUST Follow This Format:**
|
||||
|
||||
\`\`\`
|
||||
Scenario: [Descriptive name — what user action/flow is being verified]
|
||||
Tool: [Playwright / interactive_bash / Bash]
|
||||
Preconditions: [What must be true before this scenario runs]
|
||||
Steps:
|
||||
1. [Exact action with specific selector/command/endpoint]
|
||||
2. [Next action with expected intermediate state]
|
||||
3. [Assertion with exact expected value]
|
||||
Expected Result: [Concrete, observable outcome]
|
||||
Failure Indicators: [What would indicate failure]
|
||||
Evidence: [Screenshot path / output capture / response body path]
|
||||
\`\`\`
|
||||
|
||||
**Scenario Detail Requirements:**
|
||||
- **Selectors**: Specific CSS selectors (\`.login-button\`, not "the login button")
|
||||
- **Data**: Concrete test data (\`"test@example.com"\`, not \`"[email]"\`)
|
||||
- **Assertions**: Exact values (\`text contains "Welcome back"\`, not "verify it works")
|
||||
- **Timing**: Include wait conditions where relevant (\`Wait for .dashboard (timeout: 10s)\`)
|
||||
- **Negative Scenarios**: At least ONE failure/error scenario per feature
|
||||
- **Evidence Paths**: Specific file paths (\`.sisyphus/evidence/task-N-scenario-name.png\`)
|
||||
|
||||
**Anti-patterns (NEVER write scenarios like this):**
|
||||
- ❌ "Verify the login page works correctly"
|
||||
- ❌ "Check that the API returns the right data"
|
||||
- ❌ "Test the form validation"
|
||||
- ❌ "User opens browser and confirms..."
|
||||
|
||||
**Write scenarios like this instead:**
|
||||
- ✅ \`Navigate to /login → Fill input[name="email"] with "test@example.com" → Fill input[name="password"] with "Pass123!" → Click button[type="submit"] → Wait for /dashboard → Assert h1 contains "Welcome"\`
|
||||
- ✅ \`POST /api/users {"name":"Test","email":"new@test.com"} → Assert status 201 → Assert response.id is UUID → GET /api/users/{id} → Assert name equals "Test"\`
|
||||
- ✅ \`Run ./cli --config test.yaml → Wait for "Loaded" in stdout → Send "q" → Assert exit code 0 → Assert stdout contains "Goodbye"\`
|
||||
|
||||
**Evidence Requirements:**
|
||||
- Screenshots: \`.sisyphus/evidence/\` for all UI verifications
|
||||
- Terminal output: Captured for CLI/TUI verifications
|
||||
- Response bodies: Saved for API verifications
|
||||
- All evidence referenced by specific file path in acceptance criteria
|
||||
| Deliverable Type | Verification Tool | Method |
|
||||
|------------------|-------------------|--------|
|
||||
| Frontend/UI | Playwright (playwright skill) | Navigate, interact, assert DOM, screenshot |
|
||||
| TUI/CLI | interactive_bash (tmux) | Run command, send keystrokes, validate output |
|
||||
| API/Backend | Bash (curl) | Send requests, assert status + response fields |
|
||||
| Library/Module | Bash (bun/node REPL) | Import, call functions, compare output |
|
||||
|
||||
---
|
||||
|
||||
@@ -181,49 +98,82 @@ Scenario: [Descriptive name — what user action/flow is being verified]
|
||||
|
||||
> Maximize throughput by grouping independent tasks into parallel waves.
|
||||
> Each wave completes before the next begins.
|
||||
> Target: 5-8 tasks per wave. Fewer than 3 per wave (except final) = under-splitting.
|
||||
|
||||
\`\`\`
|
||||
Wave 1 (Start Immediately):
|
||||
├── Task 1: [no dependencies]
|
||||
└── Task 5: [no dependencies]
|
||||
Wave 1 (Start Immediately — foundation + scaffolding):
|
||||
├── Task 1: Project scaffolding + config [quick]
|
||||
├── Task 2: Design system tokens [quick]
|
||||
├── Task 3: Type definitions [quick]
|
||||
├── Task 4: Schema definitions [quick]
|
||||
├── Task 5: Storage interface + in-memory impl [quick]
|
||||
├── Task 6: Auth middleware [quick]
|
||||
└── Task 7: Client module [quick]
|
||||
|
||||
Wave 2 (After Wave 1):
|
||||
├── Task 2: [depends: 1]
|
||||
├── Task 3: [depends: 1]
|
||||
└── Task 6: [depends: 5]
|
||||
Wave 2 (After Wave 1 — core modules, MAX PARALLEL):
|
||||
├── Task 8: Core business logic (depends: 3, 5, 7) [deep]
|
||||
├── Task 9: API endpoints (depends: 4, 5) [unspecified-high]
|
||||
├── Task 10: Secondary storage impl (depends: 5) [unspecified-high]
|
||||
├── Task 11: Retry/fallback logic (depends: 8) [deep]
|
||||
├── Task 12: UI layout + navigation (depends: 2) [visual-engineering]
|
||||
├── Task 13: API client + hooks (depends: 4) [quick]
|
||||
└── Task 14: Telemetry middleware (depends: 5, 10) [unspecified-high]
|
||||
|
||||
Wave 3 (After Wave 2):
|
||||
└── Task 4: [depends: 2, 3]
|
||||
Wave 3 (After Wave 2 — integration + UI):
|
||||
├── Task 15: Main route combining modules (depends: 6, 11, 14) [deep]
|
||||
├── Task 16: UI data visualization (depends: 12, 13) [visual-engineering]
|
||||
├── Task 17: Deployment config A (depends: 15) [quick]
|
||||
├── Task 18: Deployment config B (depends: 15) [quick]
|
||||
├── Task 19: Deployment config C (depends: 15) [quick]
|
||||
└── Task 20: UI request log + build (depends: 16) [visual-engineering]
|
||||
|
||||
Critical Path: Task 1 → Task 2 → Task 4
|
||||
Parallel Speedup: ~40% faster than sequential
|
||||
Wave 4 (After Wave 3 — verification):
|
||||
├── Task 21: Integration tests (depends: 15) [deep]
|
||||
├── Task 22: UI QA - Playwright (depends: 20) [unspecified-high]
|
||||
├── Task 23: E2E QA (depends: 21) [deep]
|
||||
└── Task 24: Git cleanup + tagging (depends: 21) [git]
|
||||
|
||||
Wave FINAL (After ALL tasks — independent review, 4 parallel):
|
||||
├── Task F1: Plan compliance audit (oracle)
|
||||
├── Task F2: Code quality review (unspecified-high)
|
||||
├── Task F3: Real manual QA (unspecified-high)
|
||||
└── Task F4: Scope fidelity check (deep)
|
||||
|
||||
Critical Path: Task 1 → Task 5 → Task 8 → Task 11 → Task 15 → Task 21 → F1-F4
|
||||
Parallel Speedup: ~70% faster than sequential
|
||||
Max Concurrent: 7 (Waves 1 & 2)
|
||||
\`\`\`
|
||||
|
||||
### Dependency Matrix
|
||||
### Dependency Matrix (abbreviated — show ALL tasks in your generated plan)
|
||||
|
||||
| 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 | Blocks | Wave |
|
||||
|------|------------|--------|------|
|
||||
| 1-7 | — | 8-14 | 1 |
|
||||
| 8 | 3, 5, 7 | 11, 15 | 2 |
|
||||
| 11 | 8 | 15 | 2 |
|
||||
| 14 | 5, 10 | 15 | 2 |
|
||||
| 15 | 6, 11, 14 | 17-19, 21 | 3 |
|
||||
| 21 | 15 | 23, 24 | 4 |
|
||||
|
||||
> This is abbreviated for reference. YOUR generated plan must include the FULL matrix for ALL tasks.
|
||||
|
||||
### Agent Dispatch Summary
|
||||
|
||||
| Wave | Tasks | Recommended Agents |
|
||||
|------|-------|-------------------|
|
||||
| 1 | 1, 5 | task(category="...", load_skills=[...], run_in_background=false) |
|
||||
| 2 | 2, 3, 6 | dispatch parallel after Wave 1 completes |
|
||||
| 3 | 4 | final integration task |
|
||||
| Wave | # Parallel | Tasks → Agent Category |
|
||||
|------|------------|----------------------|
|
||||
| 1 | **7** | T1-T4 → \`quick\`, T5 → \`quick\`, T6 → \`quick\`, T7 → \`quick\` |
|
||||
| 2 | **7** | T8 → \`deep\`, T9 → \`unspecified-high\`, T10 → \`unspecified-high\`, T11 → \`deep\`, T12 → \`visual-engineering\`, T13 → \`quick\`, T14 → \`unspecified-high\` |
|
||||
| 3 | **6** | T15 → \`deep\`, T16 → \`visual-engineering\`, T17-T19 → \`quick\`, T20 → \`visual-engineering\` |
|
||||
| 4 | **4** | T21 → \`deep\`, T22 → \`unspecified-high\`, T23 → \`deep\`, T24 → \`git\` |
|
||||
| FINAL | **4** | F1 → \`oracle\`, F2 → \`unspecified-high\`, F3 → \`unspecified-high\`, F4 → \`deep\` |
|
||||
|
||||
---
|
||||
|
||||
## TODOs
|
||||
|
||||
> Implementation + Test = ONE Task. Never separate.
|
||||
> EVERY task MUST have: Recommended Agent Profile + Parallelization info.
|
||||
> EVERY task MUST have: Recommended Agent Profile + Parallelization info + QA Scenarios.
|
||||
> **A task WITHOUT QA Scenarios is INCOMPLETE. No exceptions.**
|
||||
|
||||
- [ ] 1. [Task Title]
|
||||
|
||||
@@ -257,22 +207,15 @@ Parallel Speedup: ~40% faster than sequential
|
||||
|
||||
**Pattern References** (existing code to follow):
|
||||
- \`src/services/auth.ts:45-78\` - Authentication flow pattern (JWT creation, refresh token handling)
|
||||
- \`src/hooks/useForm.ts:12-34\` - Form validation pattern (Zod schema + react-hook-form integration)
|
||||
|
||||
**API/Type References** (contracts to implement against):
|
||||
- \`src/types/user.ts:UserDTO\` - Response shape for user endpoints
|
||||
- \`src/api/schema.ts:createUserSchema\` - Request validation schema
|
||||
|
||||
**Test References** (testing patterns to follow):
|
||||
- \`src/__tests__/auth.test.ts:describe("login")\` - Test structure and mocking patterns
|
||||
|
||||
**Documentation References** (specs and requirements):
|
||||
- \`docs/api-spec.md#authentication\` - API contract details
|
||||
- \`ARCHITECTURE.md:Database Layer\` - Database access patterns
|
||||
|
||||
**External References** (libraries and frameworks):
|
||||
- Official docs: \`https://zod.dev/?id=basic-usage\` - Zod validation syntax
|
||||
- Example repo: \`github.com/example/project/src/auth\` - Reference implementation
|
||||
|
||||
**WHY Each Reference Matters** (explain the relevance):
|
||||
- Don't just list files - explain what pattern/information the executor should extract
|
||||
@@ -283,113 +226,60 @@ Parallel Speedup: ~40% faster than sequential
|
||||
|
||||
> **AGENT-EXECUTABLE VERIFICATION ONLY** — No human action permitted.
|
||||
> Every criterion MUST be verifiable by running a command or using a tool.
|
||||
> REPLACE all placeholders with actual values from task context.
|
||||
|
||||
**If TDD (tests enabled):**
|
||||
- [ ] 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)
|
||||
|
||||
**Agent-Executed QA Scenarios (MANDATORY — per-scenario, ultra-detailed):**
|
||||
**QA Scenarios (MANDATORY — task is INCOMPLETE without these):**
|
||||
|
||||
> Write MULTIPLE named scenarios per task: happy path AND failure cases.
|
||||
> Each scenario = exact tool + steps with real selectors/data + evidence path.
|
||||
|
||||
**Example — Frontend/UI (Playwright):**
|
||||
> **This is NOT optional. A task without QA scenarios WILL BE REJECTED.**
|
||||
>
|
||||
> Write scenario tests that verify the ACTUAL BEHAVIOR of what you built.
|
||||
> Minimum: 1 happy path + 1 failure/edge case per task.
|
||||
> Each scenario = exact tool + exact steps + exact assertions + evidence path.
|
||||
>
|
||||
> **The executing agent MUST run these scenarios after implementation.**
|
||||
> **The orchestrator WILL verify evidence files exist before marking task complete.**
|
||||
|
||||
\\\`\\\`\\\`
|
||||
Scenario: Successful login redirects to dashboard
|
||||
Tool: Playwright (playwright skill)
|
||||
Preconditions: Dev server running on localhost:3000, test user exists
|
||||
Scenario: [Happy path — what SHOULD work]
|
||||
Tool: [Playwright / interactive_bash / Bash (curl)]
|
||||
Preconditions: [Exact setup state]
|
||||
Steps:
|
||||
1. Navigate to: http://localhost:3000/login
|
||||
2. Wait for: input[name="email"] visible (timeout: 5s)
|
||||
3. Fill: input[name="email"] → "test@example.com"
|
||||
4. Fill: input[name="password"] → "ValidPass123!"
|
||||
5. Click: button[type="submit"]
|
||||
6. Wait for: navigation to /dashboard (timeout: 10s)
|
||||
7. Assert: h1 text contains "Welcome back"
|
||||
8. Assert: cookie "session_token" exists
|
||||
9. Screenshot: .sisyphus/evidence/task-1-login-success.png
|
||||
Expected Result: Dashboard loads with welcome message
|
||||
Evidence: .sisyphus/evidence/task-1-login-success.png
|
||||
1. [Exact action — specific command/selector/endpoint, no vagueness]
|
||||
2. [Next action — with expected intermediate state]
|
||||
3. [Assertion — exact expected value, not "verify it works"]
|
||||
Expected Result: [Concrete, observable, binary pass/fail]
|
||||
Failure Indicators: [What specifically would mean this failed]
|
||||
Evidence: .sisyphus/evidence/task-{N}-{scenario-slug}.{ext}
|
||||
|
||||
Scenario: Login fails with invalid credentials
|
||||
Tool: Playwright (playwright skill)
|
||||
Preconditions: Dev server running, no valid user with these credentials
|
||||
Scenario: [Failure/edge case — what SHOULD fail gracefully]
|
||||
Tool: [same format]
|
||||
Preconditions: [Invalid input / missing dependency / error state]
|
||||
Steps:
|
||||
1. Navigate to: http://localhost:3000/login
|
||||
2. Fill: input[name="email"] → "wrong@example.com"
|
||||
3. Fill: input[name="password"] → "WrongPass"
|
||||
4. Click: button[type="submit"]
|
||||
5. Wait for: .error-message visible (timeout: 5s)
|
||||
6. Assert: .error-message text contains "Invalid credentials"
|
||||
7. Assert: URL is still /login (no redirect)
|
||||
8. Screenshot: .sisyphus/evidence/task-1-login-failure.png
|
||||
Expected Result: Error message shown, stays on login page
|
||||
Evidence: .sisyphus/evidence/task-1-login-failure.png
|
||||
1. [Trigger the error condition]
|
||||
2. [Assert error is handled correctly]
|
||||
Expected Result: [Graceful failure with correct error message/code]
|
||||
Evidence: .sisyphus/evidence/task-{N}-{scenario-slug}-error.{ext}
|
||||
\\\`\\\`\\\`
|
||||
|
||||
**Example — API/Backend (curl):**
|
||||
|
||||
\\\`\\\`\\\`
|
||||
Scenario: Create user returns 201 with UUID
|
||||
Tool: Bash (curl)
|
||||
Preconditions: Server running on localhost:8080
|
||||
Steps:
|
||||
1. curl -s -w "\\n%{http_code}" -X POST http://localhost:8080/api/users \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"email":"new@test.com","name":"Test User"}'
|
||||
2. Assert: HTTP status is 201
|
||||
3. Assert: response.id matches UUID format
|
||||
4. GET /api/users/{returned-id} → Assert name equals "Test User"
|
||||
Expected Result: User created and retrievable
|
||||
Evidence: Response bodies captured
|
||||
|
||||
Scenario: Duplicate email returns 409
|
||||
Tool: Bash (curl)
|
||||
Preconditions: User with email "new@test.com" already exists
|
||||
Steps:
|
||||
1. Repeat POST with same email
|
||||
2. Assert: HTTP status is 409
|
||||
3. Assert: response.error contains "already exists"
|
||||
Expected Result: Conflict error returned
|
||||
Evidence: Response body captured
|
||||
\\\`\\\`\\\`
|
||||
|
||||
**Example — TUI/CLI (interactive_bash):**
|
||||
|
||||
\\\`\\\`\\\`
|
||||
Scenario: CLI loads config and displays menu
|
||||
Tool: interactive_bash (tmux)
|
||||
Preconditions: Binary built, test config at ./test.yaml
|
||||
Steps:
|
||||
1. tmux new-session: ./my-cli --config test.yaml
|
||||
2. Wait for: "Configuration loaded" in output (timeout: 5s)
|
||||
3. Assert: Menu items visible ("1. Create", "2. List", "3. Exit")
|
||||
4. Send keys: "3" then Enter
|
||||
5. Assert: "Goodbye" in output
|
||||
6. Assert: Process exited with code 0
|
||||
Expected Result: CLI starts, shows menu, exits cleanly
|
||||
Evidence: Terminal output captured
|
||||
|
||||
Scenario: CLI handles missing config gracefully
|
||||
Tool: interactive_bash (tmux)
|
||||
Preconditions: No config file at ./nonexistent.yaml
|
||||
Steps:
|
||||
1. tmux new-session: ./my-cli --config nonexistent.yaml
|
||||
2. Wait for: output (timeout: 3s)
|
||||
3. Assert: stderr contains "Config file not found"
|
||||
4. Assert: Process exited with code 1
|
||||
Expected Result: Meaningful error, non-zero exit
|
||||
Evidence: Error output captured
|
||||
\\\`\\\`\\\`
|
||||
> **Specificity requirements — every scenario MUST use:**
|
||||
> - **Selectors**: Specific CSS selectors (\`.login-button\`, not "the login button")
|
||||
> - **Data**: Concrete test data (\`"test@example.com"\`, not \`"[email]"\`)
|
||||
> - **Assertions**: Exact values (\`text contains "Welcome back"\`, not "verify it works")
|
||||
> - **Timing**: Wait conditions where relevant (\`timeout: 10s\`)
|
||||
> - **Negative**: At least ONE failure/error scenario per task
|
||||
>
|
||||
> **Anti-patterns (your scenario is INVALID if it looks like this):**
|
||||
> - ❌ "Verify it works correctly" — HOW? What does "correctly" mean?
|
||||
> - ❌ "Check the API returns data" — WHAT data? What fields? What values?
|
||||
> - ❌ "Test the component renders" — WHERE? What selector? What content?
|
||||
> - ❌ Any scenario without an evidence path
|
||||
|
||||
**Evidence to Capture:**
|
||||
- [ ] Screenshots in .sisyphus/evidence/ for UI scenarios
|
||||
- [ ] Terminal output for CLI/TUI scenarios
|
||||
- [ ] Response bodies for API scenarios
|
||||
- [ ] Each evidence file named: task-{N}-{scenario-slug}.{ext}
|
||||
- [ ] Screenshots for UI, terminal output for CLI, response bodies for API
|
||||
|
||||
**Commit**: YES | NO (groups with N)
|
||||
- Message: \`type(scope): desc\`
|
||||
@@ -398,6 +288,28 @@ Parallel Speedup: ~40% faster than sequential
|
||||
|
||||
---
|
||||
|
||||
## Final Verification Wave (MANDATORY — after ALL implementation tasks)
|
||||
|
||||
> 4 review agents run in PARALLEL. ALL must APPROVE. Rejection → fix → re-run.
|
||||
|
||||
- [ ] F1. **Plan Compliance Audit** — \`oracle\`
|
||||
Read the plan end-to-end. For each "Must Have": verify implementation exists (read file, curl endpoint, run command). For each "Must NOT Have": search codebase for forbidden patterns — reject with file:line if found. Check evidence files exist in .sisyphus/evidence/. Compare deliverables against plan.
|
||||
Output: \`Must Have [N/N] | Must NOT Have [N/N] | Tasks [N/N] | VERDICT: APPROVE/REJECT\`
|
||||
|
||||
- [ ] F2. **Code Quality Review** — \`unspecified-high\`
|
||||
Run \`tsc --noEmit\` + linter + \`bun test\`. Review all changed files for: \`as any\`/\`@ts-ignore\`, empty catches, console.log in prod, commented-out code, unused imports. Check AI slop: excessive comments, over-abstraction, generic names (data/result/item/temp).
|
||||
Output: \`Build [PASS/FAIL] | Lint [PASS/FAIL] | Tests [N pass/N fail] | Files [N clean/N issues] | VERDICT\`
|
||||
|
||||
- [ ] F3. **Real Manual QA** — \`unspecified-high\` (+ \`playwright\` skill if UI)
|
||||
Start from clean state. Execute EVERY QA scenario from EVERY task — follow exact steps, capture evidence. Test cross-task integration (features working together, not isolation). Test edge cases: empty state, invalid input, rapid actions. Save to \`.sisyphus/evidence/final-qa/\`.
|
||||
Output: \`Scenarios [N/N pass] | Integration [N/N] | Edge Cases [N tested] | VERDICT\`
|
||||
|
||||
- [ ] F4. **Scope Fidelity Check** — \`deep\`
|
||||
For each task: read "What to do", read actual diff (git log/diff). Verify 1:1 — everything in spec was built (no missing), nothing beyond spec was built (no creep). Check "Must NOT do" compliance. Detect cross-task contamination: Task N touching Task M's files. Flag unaccounted changes.
|
||||
Output: \`Tasks [N/N compliant] | Contamination [CLEAN/N issues] | Unaccounted [CLEAN/N files] | VERDICT\`
|
||||
|
||||
---
|
||||
|
||||
## Commit Strategy
|
||||
|
||||
| After Task | Message | Files | Verification |
|
||||
|
||||
@@ -247,7 +247,7 @@ exports[`generateModelConfig single native provider uses OpenAI models when only
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"writing": {
|
||||
"model": "openai/gpt-5.2",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -314,7 +314,7 @@ exports[`generateModelConfig single native provider uses OpenAI models with isMa
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"writing": {
|
||||
"model": "openai/gpt-5.2",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -372,6 +372,7 @@ exports[`generateModelConfig single native provider uses Gemini models when only
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "high",
|
||||
},
|
||||
"writing": {
|
||||
"model": "google/gemini-3-flash",
|
||||
@@ -432,6 +433,7 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "high",
|
||||
},
|
||||
"writing": {
|
||||
"model": "google/gemini-3-flash",
|
||||
@@ -505,6 +507,7 @@ exports[`generateModelConfig all native providers uses preferred models from fal
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "high",
|
||||
},
|
||||
"writing": {
|
||||
"model": "google/gemini-3-flash",
|
||||
@@ -579,6 +582,7 @@ exports[`generateModelConfig all native providers uses preferred models with isM
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "high",
|
||||
},
|
||||
"writing": {
|
||||
"model": "google/gemini-3-flash",
|
||||
@@ -652,6 +656,7 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "opencode/gemini-3-pro",
|
||||
"variant": "high",
|
||||
},
|
||||
"writing": {
|
||||
"model": "opencode/gemini-3-flash",
|
||||
@@ -726,6 +731,7 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "opencode/gemini-3-pro",
|
||||
"variant": "high",
|
||||
},
|
||||
"writing": {
|
||||
"model": "opencode/gemini-3-flash",
|
||||
@@ -799,6 +805,7 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "github-copilot/gemini-3-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"writing": {
|
||||
"model": "github-copilot/gemini-3-flash-preview",
|
||||
@@ -873,6 +880,7 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "github-copilot/gemini-3-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"writing": {
|
||||
"model": "github-copilot/gemini-3-flash-preview",
|
||||
@@ -927,10 +935,10 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian whe
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "zai-coding-plan/glm-4.7",
|
||||
"model": "zai-coding-plan/glm-5",
|
||||
},
|
||||
"writing": {
|
||||
"model": "zai-coding-plan/glm-4.7",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -982,10 +990,10 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian wit
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "zai-coding-plan/glm-4.7",
|
||||
"model": "zai-coding-plan/glm-5",
|
||||
},
|
||||
"writing": {
|
||||
"model": "zai-coding-plan/glm-4.7",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1056,6 +1064,7 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "opencode/gemini-3-pro",
|
||||
"variant": "high",
|
||||
},
|
||||
"writing": {
|
||||
"model": "opencode/gemini-3-flash",
|
||||
@@ -1129,6 +1138,7 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "github-copilot/gemini-3-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"writing": {
|
||||
"model": "github-copilot/gemini-3-flash-preview",
|
||||
@@ -1189,8 +1199,7 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + ZAI combinat
|
||||
"model": "anthropic/claude-sonnet-4-5",
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
"model": "zai-coding-plan/glm-5",
|
||||
},
|
||||
"writing": {
|
||||
"model": "anthropic/claude-sonnet-4-5",
|
||||
@@ -1256,6 +1265,7 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "high",
|
||||
},
|
||||
"writing": {
|
||||
"model": "google/gemini-3-flash",
|
||||
@@ -1329,6 +1339,7 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "github-copilot/gemini-3-pro-preview",
|
||||
"variant": "high",
|
||||
},
|
||||
"writing": {
|
||||
"model": "github-copilot/gemini-3-flash-preview",
|
||||
@@ -1402,6 +1413,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "high",
|
||||
},
|
||||
"writing": {
|
||||
"model": "google/gemini-3-flash",
|
||||
@@ -1476,6 +1488,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "high",
|
||||
},
|
||||
"writing": {
|
||||
"model": "google/gemini-3-flash",
|
||||
|
||||
83
src/cli/cli-installer.test.ts
Normal file
83
src/cli/cli-installer.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"
|
||||
import * as configManager from "./config-manager"
|
||||
import { runCliInstaller } from "./cli-installer"
|
||||
import type { InstallArgs } from "./types"
|
||||
|
||||
describe("runCliInstaller", () => {
|
||||
const mockConsoleLog = mock(() => {})
|
||||
const mockConsoleError = mock(() => {})
|
||||
const originalConsoleLog = console.log
|
||||
const originalConsoleError = console.error
|
||||
|
||||
beforeEach(() => {
|
||||
console.log = mockConsoleLog
|
||||
console.error = mockConsoleError
|
||||
mockConsoleLog.mockClear()
|
||||
mockConsoleError.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
console.log = originalConsoleLog
|
||||
console.error = originalConsoleError
|
||||
})
|
||||
|
||||
it("runs auth and provider setup steps when openai or copilot are enabled without gemini", async () => {
|
||||
//#given
|
||||
const addAuthPluginsSpy = spyOn(configManager, "addAuthPlugins").mockResolvedValue({
|
||||
success: true,
|
||||
configPath: "/tmp/opencode.jsonc",
|
||||
})
|
||||
const addProviderConfigSpy = spyOn(configManager, "addProviderConfig").mockReturnValue({
|
||||
success: true,
|
||||
configPath: "/tmp/opencode.jsonc",
|
||||
})
|
||||
const restoreSpies = [
|
||||
addAuthPluginsSpy,
|
||||
addProviderConfigSpy,
|
||||
spyOn(configManager, "detectCurrentConfig").mockReturnValue({
|
||||
isInstalled: false,
|
||||
hasClaude: false,
|
||||
isMax20: false,
|
||||
hasOpenAI: false,
|
||||
hasGemini: false,
|
||||
hasCopilot: false,
|
||||
hasOpencodeZen: false,
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
}),
|
||||
spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true),
|
||||
spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.0.200"),
|
||||
spyOn(configManager, "addPluginToOpenCodeConfig").mockResolvedValue({
|
||||
success: true,
|
||||
configPath: "/tmp/opencode.jsonc",
|
||||
}),
|
||||
spyOn(configManager, "writeOmoConfig").mockReturnValue({
|
||||
success: true,
|
||||
configPath: "/tmp/oh-my-opencode.jsonc",
|
||||
}),
|
||||
]
|
||||
|
||||
const args: InstallArgs = {
|
||||
tui: false,
|
||||
claude: "no",
|
||||
openai: "yes",
|
||||
gemini: "no",
|
||||
copilot: "yes",
|
||||
opencodeZen: "no",
|
||||
zaiCodingPlan: "no",
|
||||
kimiForCoding: "no",
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = await runCliInstaller(args, "3.4.0")
|
||||
|
||||
//#then
|
||||
expect(result).toBe(0)
|
||||
expect(addAuthPluginsSpy).toHaveBeenCalledTimes(1)
|
||||
expect(addProviderConfigSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
for (const spy of restoreSpies) {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -77,7 +77,9 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi
|
||||
`Plugin ${isUpdate ? "verified" : "added"} ${SYMBOLS.arrow} ${color.dim(pluginResult.configPath)}`,
|
||||
)
|
||||
|
||||
if (config.hasGemini) {
|
||||
const needsProviderSetup = config.hasGemini || config.hasOpenAI || config.hasCopilot
|
||||
|
||||
if (needsProviderSetup) {
|
||||
printStep(step++, totalSteps, "Adding auth plugins...")
|
||||
const authResult = await addAuthPlugins(config)
|
||||
if (!authResult.success) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, it, expect, spyOn, afterEach } from "bun:test"
|
||||
import type { OhMyOpenCodeConfig } from "../../config"
|
||||
import { resolveRunAgent } from "./runner"
|
||||
import { resolveRunAgent, waitForEventProcessorShutdown } from "./runner"
|
||||
|
||||
const createConfig = (overrides: Partial<OhMyOpenCodeConfig> = {}): OhMyOpenCodeConfig => ({
|
||||
...overrides,
|
||||
@@ -68,3 +70,59 @@ describe("resolveRunAgent", () => {
|
||||
expect(agent).toBe("hephaestus")
|
||||
})
|
||||
})
|
||||
|
||||
describe("waitForEventProcessorShutdown", () => {
|
||||
let consoleLogSpy: ReturnType<typeof spyOn<typeof console, "log">> | null = null
|
||||
|
||||
afterEach(() => {
|
||||
if (consoleLogSpy) {
|
||||
consoleLogSpy.mockRestore()
|
||||
consoleLogSpy = null
|
||||
}
|
||||
})
|
||||
|
||||
it("returns quickly when event processor completes", async () => {
|
||||
//#given
|
||||
const eventProcessor = new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve()
|
||||
}, 25)
|
||||
})
|
||||
consoleLogSpy = spyOn(console, "log").mockImplementation(() => {})
|
||||
const start = performance.now()
|
||||
|
||||
//#when
|
||||
await waitForEventProcessorShutdown(eventProcessor, 200)
|
||||
|
||||
//#then
|
||||
const elapsed = performance.now() - start
|
||||
expect(elapsed).toBeLessThan(200)
|
||||
expect(console.log).not.toHaveBeenCalledWith(
|
||||
"[run] Event stream did not close within 200ms after abort; continuing shutdown.",
|
||||
)
|
||||
})
|
||||
|
||||
it("times out and continues when event processor does not complete", async () => {
|
||||
//#given
|
||||
const eventProcessor = new Promise<void>(() => {})
|
||||
const spy = spyOn(console, "log").mockImplementation(() => {})
|
||||
consoleLogSpy = spy
|
||||
const timeoutMs = 50
|
||||
const start = performance.now()
|
||||
|
||||
try {
|
||||
//#when
|
||||
await waitForEventProcessorShutdown(eventProcessor, timeoutMs)
|
||||
|
||||
//#then
|
||||
const elapsed = performance.now() - start
|
||||
expect(elapsed).toBeGreaterThanOrEqual(timeoutMs)
|
||||
const callArgs = spy.mock.calls.flat().join("")
|
||||
expect(callArgs).toContain(
|
||||
`[run] Event stream did not close within ${timeoutMs}ms after abort; continuing shutdown.`,
|
||||
)
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,25 @@ import { pollForCompletion } from "./poll-for-completion"
|
||||
export { resolveRunAgent }
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 600_000
|
||||
const EVENT_PROCESSOR_SHUTDOWN_TIMEOUT_MS = 2_000
|
||||
|
||||
export async function waitForEventProcessorShutdown(
|
||||
eventProcessor: Promise<void>,
|
||||
timeoutMs = EVENT_PROCESSOR_SHUTDOWN_TIMEOUT_MS,
|
||||
): Promise<void> {
|
||||
const completed = await Promise.race([
|
||||
eventProcessor.then(() => true),
|
||||
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), timeoutMs)),
|
||||
])
|
||||
|
||||
if (!completed) {
|
||||
console.log(
|
||||
pc.dim(
|
||||
`[run] Event stream did not close within ${timeoutMs}ms after abort; continuing shutdown.`,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function run(options: RunOptions): Promise<number> {
|
||||
process.env.OPENCODE_CLI_RUN_MODE = "true"
|
||||
@@ -81,14 +100,14 @@ export async function run(options: RunOptions): Promise<number> {
|
||||
query: { directory },
|
||||
})
|
||||
|
||||
console.log(pc.dim("Waiting for completion...\n"))
|
||||
const exitCode = await pollForCompletion(ctx, eventState, abortController)
|
||||
console.log(pc.dim("Waiting for completion...\n"))
|
||||
const exitCode = await pollForCompletion(ctx, eventState, abortController)
|
||||
|
||||
// Abort the event stream to stop the processor
|
||||
abortController.abort()
|
||||
// Abort the event stream to stop the processor
|
||||
abortController.abort()
|
||||
|
||||
await eventProcessor
|
||||
cleanup()
|
||||
await waitForEventProcessorShutdown(eventProcessor)
|
||||
cleanup()
|
||||
|
||||
const durationMs = Date.now() - startTime
|
||||
|
||||
@@ -127,4 +146,3 @@ export async function run(options: RunOptions): Promise<number> {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -649,21 +649,7 @@ describe("ExperimentalConfigSchema feature flags", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("accepts team_system as boolean", () => {
|
||||
//#given
|
||||
const config = { team_system: true }
|
||||
|
||||
//#when
|
||||
const result = ExperimentalConfigSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.team_system).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("defaults team_system to false when not provided", () => {
|
||||
test("both fields are optional", () => {
|
||||
//#given
|
||||
const config = {}
|
||||
|
||||
@@ -673,34 +659,10 @@ describe("ExperimentalConfigSchema feature flags", () => {
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.team_system).toBe(false)
|
||||
expect(result.data.plugin_load_timeout_ms).toBeUndefined()
|
||||
expect(result.data.safe_hook_creation).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
test("accepts team_system as false", () => {
|
||||
//#given
|
||||
const config = { team_system: false }
|
||||
|
||||
//#when
|
||||
const result = ExperimentalConfigSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.team_system).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects non-boolean team_system", () => {
|
||||
//#given
|
||||
const config = { team_system: "true" }
|
||||
|
||||
//#when
|
||||
const result = ExperimentalConfigSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("GitMasterConfigSchema", () => {
|
||||
|
||||
@@ -6,6 +6,8 @@ export const BackgroundTaskConfigSchema = z.object({
|
||||
modelConcurrency: z.record(z.string(), z.number().min(0)).optional(),
|
||||
/** Stale timeout in milliseconds - interrupt tasks with no activity for this duration (default: 180000 = 3 minutes, minimum: 60000 = 1 minute) */
|
||||
staleTimeoutMs: z.number().min(60000).optional(),
|
||||
/** Timeout for tasks that never received any progress update, falling back to startedAt (default: 600000 = 10 minutes, minimum: 60000 = 1 minute) */
|
||||
messageStalenessTimeoutMs: z.number().min(60000).optional(),
|
||||
})
|
||||
|
||||
export type BackgroundTaskConfig = z.infer<typeof BackgroundTaskConfigSchema>
|
||||
|
||||
@@ -15,10 +15,6 @@ export const ExperimentalConfigSchema = z.object({
|
||||
plugin_load_timeout_ms: z.number().min(1000).optional(),
|
||||
/** Wrap hook creation in try/catch to prevent one failing hook from crashing the plugin (default: true at call site) */
|
||||
safe_hook_creation: z.boolean().optional(),
|
||||
/** Enable experimental agent teams toolset (default: false) */
|
||||
agent_teams: z.boolean().optional(),
|
||||
/** Enable experimental team system (default: false) */
|
||||
team_system: z.boolean().default(false),
|
||||
})
|
||||
|
||||
export type ExperimentalConfig = z.infer<typeof ExperimentalConfigSchema>
|
||||
|
||||
@@ -52,7 +52,7 @@ export function handleBackgroundEvent(args: {
|
||||
|
||||
const props = event.properties
|
||||
|
||||
if (event.type === "message.part.updated") {
|
||||
if (event.type === "message.part.updated" || event.type === "message.part.delta") {
|
||||
if (!props || !isRecord(props)) return
|
||||
const sessionID = getString(props, "sessionID")
|
||||
if (!sessionID) return
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { BackgroundTask, LaunchInput } from "./types"
|
||||
export const TASK_TTL_MS = 30 * 60 * 1000
|
||||
export const MIN_STABILITY_TIME_MS = 10 * 1000
|
||||
export const DEFAULT_STALE_TIMEOUT_MS = 180_000
|
||||
export const DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS = 600_000
|
||||
export const MIN_RUNTIME_BEFORE_STALE_MS = 30_000
|
||||
export const MIN_IDLE_TIME_MS = 5000
|
||||
export const POLLING_INTERVAL_MS = 3000
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { BackgroundTask, ResumeInput } from "./types"
|
||||
import { MIN_IDLE_TIME_MS } from "./constants"
|
||||
import { BackgroundManager } from "./manager"
|
||||
import { ConcurrencyManager } from "./concurrency"
|
||||
import { initTaskToastManager, _resetTaskToastManagerForTesting } from "../task-toast-manager/manager"
|
||||
|
||||
|
||||
const TASK_TTL_MS = 30 * 60 * 1000
|
||||
@@ -190,6 +191,10 @@ function getPendingByParent(manager: BackgroundManager): Map<string, Set<string>
|
||||
return (manager as unknown as { pendingByParent: Map<string, Set<string>> }).pendingByParent
|
||||
}
|
||||
|
||||
function getCompletionTimers(manager: BackgroundManager): Map<string, ReturnType<typeof setTimeout>> {
|
||||
return (manager as unknown as { completionTimers: Map<string, ReturnType<typeof setTimeout>> }).completionTimers
|
||||
}
|
||||
|
||||
function getQueuesByKey(
|
||||
manager: BackgroundManager
|
||||
): Map<string, Array<{ task: BackgroundTask; input: import("./types").LaunchInput }>> {
|
||||
@@ -215,6 +220,23 @@ function stubNotifyParentSession(manager: BackgroundManager): void {
|
||||
;(manager as unknown as { notifyParentSession: () => Promise<void> }).notifyParentSession = async () => {}
|
||||
}
|
||||
|
||||
function createToastRemoveTaskTracker(): { removeTaskCalls: string[]; resetToastManager: () => void } {
|
||||
_resetTaskToastManagerForTesting()
|
||||
const toastManager = initTaskToastManager({
|
||||
tui: { showToast: async () => {} },
|
||||
} as unknown as PluginInput["client"])
|
||||
const removeTaskCalls: string[] = []
|
||||
const originalRemoveTask = toastManager.removeTask.bind(toastManager)
|
||||
toastManager.removeTask = (taskId: string): void => {
|
||||
removeTaskCalls.push(taskId)
|
||||
originalRemoveTask(taskId)
|
||||
}
|
||||
return {
|
||||
removeTaskCalls,
|
||||
resetToastManager: _resetTaskToastManagerForTesting,
|
||||
}
|
||||
}
|
||||
|
||||
function getCleanupSignals(): Array<NodeJS.Signals | "beforeExit" | "exit"> {
|
||||
const signals: Array<NodeJS.Signals | "beforeExit" | "exit"> = ["SIGINT", "SIGTERM", "beforeExit", "exit"]
|
||||
if (process.platform === "win32") {
|
||||
@@ -894,7 +916,7 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () =>
|
||||
})
|
||||
|
||||
describe("BackgroundManager.notifyParentSession - aborted parent", () => {
|
||||
test("should skip notification when parent session is aborted", async () => {
|
||||
test("should fall back and still notify when parent session messages are aborted", async () => {
|
||||
//#given
|
||||
let promptCalled = false
|
||||
const promptMock = async () => {
|
||||
@@ -933,7 +955,7 @@ describe("BackgroundManager.notifyParentSession - aborted parent", () => {
|
||||
.notifyParentSession(task)
|
||||
|
||||
//#then
|
||||
expect(promptCalled).toBe(false)
|
||||
expect(promptCalled).toBe(true)
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
@@ -1770,6 +1792,32 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
const pendingSet = pendingByParent.get(task.parentSessionID)
|
||||
expect(pendingSet?.has(task.id) ?? false).toBe(false)
|
||||
})
|
||||
|
||||
test("should remove task from toast manager when notification is skipped", async () => {
|
||||
//#given
|
||||
const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker()
|
||||
const manager = createBackgroundManager()
|
||||
const task = createMockTask({
|
||||
id: "task-cancel-skip-notification",
|
||||
sessionID: "session-cancel-skip-notification",
|
||||
parentSessionID: "parent-cancel-skip-notification",
|
||||
status: "running",
|
||||
})
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
//#when
|
||||
const cancelled = await manager.cancelTask(task.id, {
|
||||
source: "test",
|
||||
skipNotification: true,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(cancelled).toBe(true)
|
||||
expect(removeTaskCalls).toContain(task.id)
|
||||
|
||||
manager.shutdown()
|
||||
resetToastManager()
|
||||
})
|
||||
})
|
||||
|
||||
describe("multiple keys process in parallel", () => {
|
||||
@@ -2289,10 +2337,221 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
|
||||
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
await manager["checkAndInterruptStaleTasks"]()
|
||||
await manager["checkAndInterruptStaleTasks"]()
|
||||
|
||||
expect(task.status).toBe("cancelled")
|
||||
})
|
||||
|
||||
test("should NOT interrupt task when session is running, even with stale lastUpdate", async () => {
|
||||
//#given
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async () => ({}),
|
||||
promptAsync: async () => ({}),
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
|
||||
|
||||
const task: BackgroundTask = {
|
||||
id: "task-running-session",
|
||||
sessionID: "session-running",
|
||||
parentSessionID: "parent-rs",
|
||||
parentMessageID: "msg-rs",
|
||||
description: "Task with running session",
|
||||
prompt: "Test",
|
||||
agent: "test-agent",
|
||||
status: "running",
|
||||
startedAt: new Date(Date.now() - 300_000),
|
||||
progress: {
|
||||
toolCalls: 2,
|
||||
lastUpdate: new Date(Date.now() - 300_000),
|
||||
},
|
||||
}
|
||||
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
//#when — session is actively running
|
||||
await manager["checkAndInterruptStaleTasks"]({ "session-running": { type: "running" } })
|
||||
|
||||
//#then — task survives because session is running
|
||||
expect(task.status).toBe("running")
|
||||
})
|
||||
|
||||
test("should interrupt task when session is idle and lastUpdate exceeds stale timeout", async () => {
|
||||
//#given
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async () => ({}),
|
||||
promptAsync: async () => ({}),
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
|
||||
stubNotifyParentSession(manager)
|
||||
|
||||
const task: BackgroundTask = {
|
||||
id: "task-idle-session",
|
||||
sessionID: "session-idle",
|
||||
parentSessionID: "parent-is",
|
||||
parentMessageID: "msg-is",
|
||||
description: "Task with idle session",
|
||||
prompt: "Test",
|
||||
agent: "test-agent",
|
||||
status: "running",
|
||||
startedAt: new Date(Date.now() - 300_000),
|
||||
progress: {
|
||||
toolCalls: 2,
|
||||
lastUpdate: new Date(Date.now() - 300_000),
|
||||
},
|
||||
}
|
||||
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
//#when — session is idle
|
||||
await manager["checkAndInterruptStaleTasks"]({ "session-idle": { type: "idle" } })
|
||||
|
||||
//#then — killed because session is idle with stale lastUpdate
|
||||
expect(task.status).toBe("cancelled")
|
||||
expect(task.error).toContain("Stale timeout")
|
||||
})
|
||||
|
||||
test("should NOT interrupt running session even with very old lastUpdate (no safety net)", async () => {
|
||||
//#given
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async () => ({}),
|
||||
promptAsync: async () => ({}),
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
|
||||
|
||||
const task: BackgroundTask = {
|
||||
id: "task-long-running",
|
||||
sessionID: "session-long",
|
||||
parentSessionID: "parent-lr",
|
||||
parentMessageID: "msg-lr",
|
||||
description: "Long running task",
|
||||
prompt: "Test",
|
||||
agent: "test-agent",
|
||||
status: "running",
|
||||
startedAt: new Date(Date.now() - 900_000),
|
||||
progress: {
|
||||
toolCalls: 5,
|
||||
lastUpdate: new Date(Date.now() - 900_000),
|
||||
},
|
||||
}
|
||||
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
//#when — session is running, lastUpdate 15min old
|
||||
await manager["checkAndInterruptStaleTasks"]({ "session-long": { type: "running" } })
|
||||
|
||||
//#then — running sessions are NEVER stale-killed
|
||||
expect(task.status).toBe("running")
|
||||
})
|
||||
|
||||
test("should NOT interrupt running session with no progress (undefined lastUpdate)", async () => {
|
||||
//#given — no progress at all, but session is running
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async () => ({}),
|
||||
promptAsync: async () => ({}),
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { messageStalenessTimeoutMs: 600_000 })
|
||||
|
||||
const task: BackgroundTask = {
|
||||
id: "task-running-no-progress",
|
||||
sessionID: "session-rnp",
|
||||
parentSessionID: "parent-rnp",
|
||||
parentMessageID: "msg-rnp",
|
||||
description: "Running no progress",
|
||||
prompt: "Test",
|
||||
agent: "test-agent",
|
||||
status: "running",
|
||||
startedAt: new Date(Date.now() - 15 * 60 * 1000),
|
||||
progress: undefined,
|
||||
}
|
||||
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
//#when — session is running despite no progress
|
||||
await manager["checkAndInterruptStaleTasks"]({ "session-rnp": { type: "running" } })
|
||||
|
||||
//#then — running sessions are NEVER killed
|
||||
expect(task.status).toBe("running")
|
||||
})
|
||||
|
||||
test("should interrupt task with no lastUpdate after messageStalenessTimeout", async () => {
|
||||
//#given
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async () => ({}),
|
||||
promptAsync: async () => ({}),
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { messageStalenessTimeoutMs: 600_000 })
|
||||
stubNotifyParentSession(manager)
|
||||
|
||||
const task: BackgroundTask = {
|
||||
id: "task-no-update",
|
||||
sessionID: "session-no-update",
|
||||
parentSessionID: "parent-nu",
|
||||
parentMessageID: "msg-nu",
|
||||
description: "No update task",
|
||||
prompt: "Test",
|
||||
agent: "test-agent",
|
||||
status: "running",
|
||||
startedAt: new Date(Date.now() - 15 * 60 * 1000),
|
||||
progress: undefined,
|
||||
}
|
||||
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
//#when — no progress update for 15 minutes
|
||||
await manager["checkAndInterruptStaleTasks"]({})
|
||||
|
||||
//#then — killed after messageStalenessTimeout
|
||||
expect(task.status).toBe("cancelled")
|
||||
expect(task.error).toContain("no activity")
|
||||
})
|
||||
|
||||
test("should NOT interrupt task with no lastUpdate within messageStalenessTimeout", async () => {
|
||||
//#given
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async () => ({}),
|
||||
promptAsync: async () => ({}),
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { messageStalenessTimeoutMs: 600_000 })
|
||||
|
||||
const task: BackgroundTask = {
|
||||
id: "task-fresh-no-update",
|
||||
sessionID: "session-fresh",
|
||||
parentSessionID: "parent-fn",
|
||||
parentMessageID: "msg-fn",
|
||||
description: "Fresh no-update task",
|
||||
prompt: "Test",
|
||||
agent: "test-agent",
|
||||
status: "running",
|
||||
startedAt: new Date(Date.now() - 5 * 60 * 1000),
|
||||
progress: undefined,
|
||||
}
|
||||
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
//#when — only 5 min since start, within 10min timeout
|
||||
await manager["checkAndInterruptStaleTasks"]({})
|
||||
|
||||
//#then — task survives
|
||||
expect(task.status).toBe("running")
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager.shutdown session abort", () => {
|
||||
@@ -2519,6 +2778,43 @@ describe("BackgroundManager.handleEvent - session.deleted cascade", () => {
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
test("should remove tasks from toast manager when session is deleted", () => {
|
||||
//#given
|
||||
const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker()
|
||||
const manager = createBackgroundManager()
|
||||
const parentSessionID = "session-parent-toast"
|
||||
const childTask = createMockTask({
|
||||
id: "task-child-toast",
|
||||
sessionID: "session-child-toast",
|
||||
parentSessionID,
|
||||
status: "running",
|
||||
})
|
||||
const grandchildTask = createMockTask({
|
||||
id: "task-grandchild-toast",
|
||||
sessionID: "session-grandchild-toast",
|
||||
parentSessionID: "session-child-toast",
|
||||
status: "pending",
|
||||
startedAt: undefined,
|
||||
queuedAt: new Date(),
|
||||
})
|
||||
const taskMap = getTaskMap(manager)
|
||||
taskMap.set(childTask.id, childTask)
|
||||
taskMap.set(grandchildTask.id, grandchildTask)
|
||||
|
||||
//#when
|
||||
manager.handleEvent({
|
||||
type: "session.deleted",
|
||||
properties: { info: { id: parentSessionID } },
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(removeTaskCalls).toContain(childTask.id)
|
||||
expect(removeTaskCalls).toContain(grandchildTask.id)
|
||||
|
||||
manager.shutdown()
|
||||
resetToastManager()
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager.handleEvent - session.error", () => {
|
||||
@@ -2566,6 +2862,35 @@ describe("BackgroundManager.handleEvent - session.error", () => {
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
test("removes errored task from toast manager", () => {
|
||||
//#given
|
||||
const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker()
|
||||
const manager = createBackgroundManager()
|
||||
const sessionID = "ses_error_toast"
|
||||
const task = createMockTask({
|
||||
id: "task-session-error-toast",
|
||||
sessionID,
|
||||
parentSessionID: "parent-session",
|
||||
status: "running",
|
||||
})
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
//#when
|
||||
manager.handleEvent({
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
error: { name: "UnknownError", message: "boom" },
|
||||
},
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(removeTaskCalls).toContain(task.id)
|
||||
|
||||
manager.shutdown()
|
||||
resetToastManager()
|
||||
})
|
||||
|
||||
test("ignores session.error for non-running tasks", () => {
|
||||
//#given
|
||||
const manager = createBackgroundManager()
|
||||
@@ -2711,13 +3036,32 @@ describe("BackgroundManager.pruneStaleTasksAndNotifications - removes pruned tas
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
test("removes stale task from toast manager", () => {
|
||||
//#given
|
||||
const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker()
|
||||
const manager = createBackgroundManager()
|
||||
const staleTask = createMockTask({
|
||||
id: "task-stale-toast",
|
||||
sessionID: "session-stale-toast",
|
||||
parentSessionID: "parent-session",
|
||||
status: "running",
|
||||
startedAt: new Date(Date.now() - 31 * 60 * 1000),
|
||||
})
|
||||
getTaskMap(manager).set(staleTask.id, staleTask)
|
||||
|
||||
//#when
|
||||
pruneStaleTasksAndNotificationsForTest(manager)
|
||||
|
||||
//#then
|
||||
expect(removeTaskCalls).toContain(staleTask.id)
|
||||
|
||||
manager.shutdown()
|
||||
resetToastManager()
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager.completionTimers - Memory Leak Fix", () => {
|
||||
function getCompletionTimers(manager: BackgroundManager): Map<string, ReturnType<typeof setTimeout>> {
|
||||
return (manager as unknown as { completionTimers: Map<string, ReturnType<typeof setTimeout>> }).completionTimers
|
||||
}
|
||||
|
||||
function setCompletionTimer(manager: BackgroundManager, taskId: string): void {
|
||||
const completionTimers = getCompletionTimers(manager)
|
||||
const timer = setTimeout(() => {
|
||||
@@ -3202,4 +3546,134 @@ describe("BackgroundManager.handleEvent - non-tool event lastUpdate", () => {
|
||||
//#then - task should still be running (text event refreshed lastUpdate)
|
||||
expect(task.status).toBe("running")
|
||||
})
|
||||
|
||||
test("should refresh lastUpdate on message.part.delta events (OpenCode >=1.2.0)", async () => {
|
||||
//#given - a running task with stale lastUpdate
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async () => ({}),
|
||||
promptAsync: async () => ({}),
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
|
||||
stubNotifyParentSession(manager)
|
||||
|
||||
const task: BackgroundTask = {
|
||||
id: "task-delta-1",
|
||||
sessionID: "session-delta-1",
|
||||
parentSessionID: "parent-1",
|
||||
parentMessageID: "msg-1",
|
||||
description: "Reasoning task with delta events",
|
||||
prompt: "Extended thinking",
|
||||
agent: "oracle",
|
||||
status: "running",
|
||||
startedAt: new Date(Date.now() - 600_000),
|
||||
progress: {
|
||||
toolCalls: 0,
|
||||
lastUpdate: new Date(Date.now() - 300_000),
|
||||
},
|
||||
}
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
//#when - a message.part.delta event arrives (reasoning-delta or text-delta in OpenCode >=1.2.0)
|
||||
manager.handleEvent({
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID: "session-delta-1", field: "text", delta: "thinking..." },
|
||||
})
|
||||
await manager["checkAndInterruptStaleTasks"]()
|
||||
|
||||
//#then - task should still be running (delta event refreshed lastUpdate)
|
||||
expect(task.status).toBe("running")
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager regression fixes - resume and aborted notification", () => {
|
||||
test("should keep resumed task in memory after previous completion timer deadline", async () => {
|
||||
//#given
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async () => ({}),
|
||||
promptAsync: async () => ({}),
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
|
||||
|
||||
const task: BackgroundTask = {
|
||||
id: "task-resume-timer-regression",
|
||||
sessionID: "session-resume-timer-regression",
|
||||
parentSessionID: "parent-session",
|
||||
parentMessageID: "msg-1",
|
||||
description: "resume timer regression",
|
||||
prompt: "test",
|
||||
agent: "explore",
|
||||
status: "completed",
|
||||
startedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
concurrencyGroup: "explore",
|
||||
}
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
const completionTimers = getCompletionTimers(manager)
|
||||
const timer = setTimeout(() => {
|
||||
completionTimers.delete(task.id)
|
||||
getTaskMap(manager).delete(task.id)
|
||||
}, 25)
|
||||
completionTimers.set(task.id, timer)
|
||||
|
||||
//#when
|
||||
await manager.resume({
|
||||
sessionId: "session-resume-timer-regression",
|
||||
prompt: "resume task",
|
||||
parentSessionID: "parent-session-2",
|
||||
parentMessageID: "msg-2",
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 60))
|
||||
|
||||
//#then
|
||||
expect(getTaskMap(manager).has(task.id)).toBe(true)
|
||||
expect(completionTimers.has(task.id)).toBe(false)
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
test("should start cleanup timer even when promptAsync aborts", async () => {
|
||||
//#given
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async () => ({}),
|
||||
promptAsync: async () => {
|
||||
const error = new Error("User aborted")
|
||||
error.name = "MessageAbortedError"
|
||||
throw error
|
||||
},
|
||||
abort: async () => ({}),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
|
||||
const task: BackgroundTask = {
|
||||
id: "task-aborted-cleanup-regression",
|
||||
sessionID: "session-aborted-cleanup-regression",
|
||||
parentSessionID: "parent-session",
|
||||
parentMessageID: "msg-1",
|
||||
description: "aborted prompt cleanup regression",
|
||||
prompt: "test",
|
||||
agent: "explore",
|
||||
status: "completed",
|
||||
startedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
}
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
getPendingByParent(manager).set(task.parentSessionID, new Set([task.id]))
|
||||
|
||||
//#when
|
||||
await (manager as unknown as { notifyParentSession: (task: BackgroundTask) => Promise<void> }).notifyParentSession(task)
|
||||
|
||||
//#then
|
||||
expect(getCompletionTimers(manager).has(task.id)).toBe(true)
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,10 +7,12 @@ import type {
|
||||
} from "./types"
|
||||
import { TaskHistory } from "./task-history"
|
||||
import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry } from "../../shared"
|
||||
import { setSessionTools } from "../../shared/session-tools-store"
|
||||
import { ConcurrencyManager } from "./concurrency"
|
||||
import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema"
|
||||
import { isInsideTmux } from "../../shared/tmux"
|
||||
import {
|
||||
DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS,
|
||||
DEFAULT_STALE_TIMEOUT_MS,
|
||||
MIN_IDLE_TIME_MS,
|
||||
MIN_RUNTIME_BEFORE_STALE_MS,
|
||||
@@ -141,6 +143,7 @@ export class BackgroundManager {
|
||||
parentMessageID: input.parentMessageID,
|
||||
parentModel: input.parentModel,
|
||||
parentAgent: input.parentAgent,
|
||||
parentTools: input.parentTools,
|
||||
model: input.model,
|
||||
category: input.category,
|
||||
}
|
||||
@@ -328,12 +331,16 @@ export class BackgroundManager {
|
||||
...(launchModel ? { model: launchModel } : {}),
|
||||
...(launchVariant ? { variant: launchVariant } : {}),
|
||||
system: input.skillContent,
|
||||
tools: {
|
||||
...getAgentToolRestrictions(input.agent),
|
||||
task: false,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
},
|
||||
tools: (() => {
|
||||
const tools = {
|
||||
...getAgentToolRestrictions(input.agent),
|
||||
task: false,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
}
|
||||
setSessionTools(sessionID, tools)
|
||||
return tools
|
||||
})(),
|
||||
parts: [{ type: "text", text: input.prompt }],
|
||||
},
|
||||
}).catch((error) => {
|
||||
@@ -521,6 +528,12 @@ export class BackgroundManager {
|
||||
return existingTask
|
||||
}
|
||||
|
||||
const completionTimer = this.completionTimers.get(existingTask.id)
|
||||
if (completionTimer) {
|
||||
clearTimeout(completionTimer)
|
||||
this.completionTimers.delete(existingTask.id)
|
||||
}
|
||||
|
||||
// Re-acquire concurrency using the persisted concurrency group
|
||||
const concurrencyKey = existingTask.concurrencyGroup ?? existingTask.agent
|
||||
await this.concurrencyManager.acquire(concurrencyKey)
|
||||
@@ -535,6 +548,9 @@ export class BackgroundManager {
|
||||
existingTask.parentMessageID = input.parentMessageID
|
||||
existingTask.parentModel = input.parentModel
|
||||
existingTask.parentAgent = input.parentAgent
|
||||
if (input.parentTools) {
|
||||
existingTask.parentTools = input.parentTools
|
||||
}
|
||||
// Reset startedAt on resume to prevent immediate completion
|
||||
// The MIN_IDLE_TIME_MS check uses startedAt, so resumed tasks need fresh timing
|
||||
existingTask.startedAt = new Date()
|
||||
@@ -588,12 +604,16 @@ export class BackgroundManager {
|
||||
agent: existingTask.agent,
|
||||
...(resumeModel ? { model: resumeModel } : {}),
|
||||
...(resumeVariant ? { variant: resumeVariant } : {}),
|
||||
tools: {
|
||||
...getAgentToolRestrictions(existingTask.agent),
|
||||
task: false,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
},
|
||||
tools: (() => {
|
||||
const tools = {
|
||||
...getAgentToolRestrictions(existingTask.agent),
|
||||
task: false,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
}
|
||||
setSessionTools(existingTask.sessionID!, tools)
|
||||
return tools
|
||||
})(),
|
||||
parts: [{ type: "text", text: input.prompt }],
|
||||
},
|
||||
}).catch((error) => {
|
||||
@@ -646,7 +666,7 @@ export class BackgroundManager {
|
||||
handleEvent(event: Event): void {
|
||||
const props = event.properties
|
||||
|
||||
if (event.type === "message.part.updated") {
|
||||
if (event.type === "message.part.updated" || event.type === "message.part.delta") {
|
||||
if (!props || typeof props !== "object" || !("sessionID" in props)) return
|
||||
const partInfo = props as unknown as MessagePartInfo
|
||||
const sessionID = partInfo?.sessionID
|
||||
@@ -769,6 +789,10 @@ export class BackgroundManager {
|
||||
this.cleanupPendingByParent(task)
|
||||
this.tasks.delete(task.id)
|
||||
this.clearNotificationsForTask(task.id)
|
||||
const toastManager = getTaskToastManager()
|
||||
if (toastManager) {
|
||||
toastManager.removeTask(task.id)
|
||||
}
|
||||
if (task.sessionID) {
|
||||
subagentSessions.delete(task.sessionID)
|
||||
}
|
||||
@@ -816,6 +840,10 @@ export class BackgroundManager {
|
||||
this.cleanupPendingByParent(task)
|
||||
this.tasks.delete(task.id)
|
||||
this.clearNotificationsForTask(task.id)
|
||||
const toastManager = getTaskToastManager()
|
||||
if (toastManager) {
|
||||
toastManager.removeTask(task.id)
|
||||
}
|
||||
if (task.sessionID) {
|
||||
subagentSessions.delete(task.sessionID)
|
||||
}
|
||||
@@ -986,6 +1014,10 @@ export class BackgroundManager {
|
||||
}
|
||||
|
||||
if (options?.skipNotification) {
|
||||
const toastManager = getTaskToastManager()
|
||||
if (toastManager) {
|
||||
toastManager.removeTask(task.id)
|
||||
}
|
||||
log(`[background-agent] Task cancelled via ${source} (notification skipped):`, task.id)
|
||||
return true
|
||||
}
|
||||
@@ -1225,11 +1257,10 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.isAbortedSessionError(error)) {
|
||||
log("[background-agent] Parent session aborted, skipping notification:", {
|
||||
log("[background-agent] Parent session aborted while loading messages; using messageDir fallback:", {
|
||||
taskId: task.id,
|
||||
parentSessionID: task.parentSessionID,
|
||||
})
|
||||
return
|
||||
}
|
||||
const messageDir = getMessageDir(task.parentSessionID)
|
||||
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
@@ -1252,6 +1283,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
noReply: !allComplete,
|
||||
...(agent !== undefined ? { agent } : {}),
|
||||
...(model !== undefined ? { model } : {}),
|
||||
...(task.parentTools ? { tools: task.parentTools } : {}),
|
||||
parts: [{ type: "text", text: notification }],
|
||||
},
|
||||
})
|
||||
@@ -1262,13 +1294,13 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
})
|
||||
} catch (error) {
|
||||
if (this.isAbortedSessionError(error)) {
|
||||
log("[background-agent] Parent session aborted, skipping notification:", {
|
||||
log("[background-agent] Parent session aborted while sending notification; continuing cleanup:", {
|
||||
taskId: task.id,
|
||||
parentSessionID: task.parentSessionID,
|
||||
})
|
||||
return
|
||||
} else {
|
||||
log("[background-agent] Failed to send notification:", error)
|
||||
}
|
||||
log("[background-agent] Failed to send notification:", error)
|
||||
}
|
||||
|
||||
if (allComplete) {
|
||||
@@ -1398,6 +1430,10 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
}
|
||||
}
|
||||
this.clearNotificationsForTask(taskId)
|
||||
const toastManager = getTaskToastManager()
|
||||
if (toastManager) {
|
||||
toastManager.removeTask(taskId)
|
||||
}
|
||||
this.tasks.delete(taskId)
|
||||
if (task.sessionID) {
|
||||
subagentSessions.delete(task.sessionID)
|
||||
@@ -1423,24 +1459,55 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
}
|
||||
}
|
||||
|
||||
private async checkAndInterruptStaleTasks(): Promise<void> {
|
||||
private async checkAndInterruptStaleTasks(
|
||||
allStatuses: Record<string, { type: string }> = {},
|
||||
): Promise<void> {
|
||||
const staleTimeoutMs = this.config?.staleTimeoutMs ?? DEFAULT_STALE_TIMEOUT_MS
|
||||
const messageStalenessMs = this.config?.messageStalenessTimeoutMs ?? DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS
|
||||
const now = Date.now()
|
||||
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.status !== "running") continue
|
||||
if (!task.progress?.lastUpdate) continue
|
||||
|
||||
|
||||
const startedAt = task.startedAt
|
||||
const sessionID = task.sessionID
|
||||
if (!startedAt || !sessionID) continue
|
||||
|
||||
const sessionStatus = allStatuses[sessionID]?.type
|
||||
const sessionIsRunning = sessionStatus !== undefined && sessionStatus !== "idle"
|
||||
const runtime = now - startedAt.getTime()
|
||||
|
||||
if (!task.progress?.lastUpdate) {
|
||||
if (sessionIsRunning) continue
|
||||
if (runtime <= messageStalenessMs) continue
|
||||
|
||||
const staleMinutes = Math.round(runtime / 60000)
|
||||
task.status = "cancelled"
|
||||
task.error = `Stale timeout (no activity for ${staleMinutes}min since start)`
|
||||
task.completedAt = new Date()
|
||||
|
||||
if (task.concurrencyKey) {
|
||||
this.concurrencyManager.release(task.concurrencyKey)
|
||||
task.concurrencyKey = undefined
|
||||
}
|
||||
|
||||
this.client.session.abort({ path: { id: sessionID } }).catch(() => {})
|
||||
log(`[background-agent] Task ${task.id} interrupted: no progress since start`)
|
||||
|
||||
try {
|
||||
await this.enqueueNotificationForParent(task.parentSessionID, () => this.notifyParentSession(task))
|
||||
} catch (err) {
|
||||
log("[background-agent] Error in notifyParentSession for stale task:", { taskId: task.id, error: err })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (sessionIsRunning) continue
|
||||
|
||||
if (runtime < MIN_RUNTIME_BEFORE_STALE_MS) continue
|
||||
|
||||
const timeSinceLastUpdate = now - task.progress.lastUpdate.getTime()
|
||||
if (timeSinceLastUpdate <= staleTimeoutMs) continue
|
||||
|
||||
if (task.status !== "running") continue
|
||||
|
||||
const staleMinutes = Math.round(timeSinceLastUpdate / 60000)
|
||||
@@ -1453,10 +1520,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
task.concurrencyKey = undefined
|
||||
}
|
||||
|
||||
this.client.session.abort({
|
||||
path: { id: sessionID },
|
||||
}).catch(() => {})
|
||||
|
||||
this.client.session.abort({ path: { id: sessionID } }).catch(() => {})
|
||||
log(`[background-agent] Task ${task.id} interrupted: stale timeout`)
|
||||
|
||||
try {
|
||||
@@ -1469,11 +1533,12 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
|
||||
private async pollRunningTasks(): Promise<void> {
|
||||
this.pruneStaleTasksAndNotifications()
|
||||
await this.checkAndInterruptStaleTasks()
|
||||
|
||||
const statusResult = await this.client.session.status()
|
||||
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>
|
||||
|
||||
await this.checkAndInterruptStaleTasks(allStatuses)
|
||||
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.status !== "running") continue
|
||||
|
||||
@@ -1483,7 +1548,6 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
try {
|
||||
const sessionStatus = allStatuses[sessionID]
|
||||
|
||||
// Don't skip if session not in status - fall through to message-based detection
|
||||
if (sessionStatus?.type === "idle") {
|
||||
// Edge guard: Validate session has actual output before completing
|
||||
const hasValidOutput = await this.validateSessionHasOutput(sessionID)
|
||||
|
||||
@@ -148,6 +148,7 @@ export async function notifyParentSession(args: {
|
||||
noReply: !allComplete,
|
||||
...(agent !== undefined ? { agent } : {}),
|
||||
...(model !== undefined ? { model } : {}),
|
||||
...(task.parentTools ? { tools: task.parentTools } : {}),
|
||||
parts: [{ type: "text", text: notification }],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -71,6 +71,7 @@ export async function notifyParentSession(
|
||||
noReply: !allComplete,
|
||||
...(agent !== undefined ? { agent } : {}),
|
||||
...(model !== undefined ? { model } : {}),
|
||||
...(task.parentTools ? { tools: task.parentTools } : {}),
|
||||
parts: [{ type: "text", text: notification }],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -34,7 +34,7 @@ export async function pollRunningTasks(args: {
|
||||
tasks: Iterable<BackgroundTask>
|
||||
client: OpencodeClient
|
||||
pruneStaleTasksAndNotifications: () => void
|
||||
checkAndInterruptStaleTasks: () => Promise<void>
|
||||
checkAndInterruptStaleTasks: (statuses: Record<string, { type: string }>) => Promise<void>
|
||||
validateSessionHasOutput: (sessionID: string) => Promise<boolean>
|
||||
checkSessionTodos: (sessionID: string) => Promise<boolean>
|
||||
tryCompleteTask: (task: BackgroundTask, source: string) => Promise<boolean>
|
||||
@@ -54,11 +54,12 @@ export async function pollRunningTasks(args: {
|
||||
} = args
|
||||
|
||||
pruneStaleTasksAndNotifications()
|
||||
await checkAndInterruptStaleTasks()
|
||||
|
||||
const statusResult = await client.session.status()
|
||||
const allStatuses = ((statusResult as { data?: unknown }).data ?? {}) as SessionStatusMap
|
||||
|
||||
await checkAndInterruptStaleTasks(allStatuses)
|
||||
|
||||
for (const task of tasks) {
|
||||
if (task.status !== "running") continue
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export function createTask(input: LaunchInput): BackgroundTask {
|
||||
parentMessageID: input.parentMessageID,
|
||||
parentModel: input.parentModel,
|
||||
parentAgent: input.parentAgent,
|
||||
parentTools: input.parentTools,
|
||||
model: input.model,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { BackgroundTask, ResumeInput } from "../types"
|
||||
import { log, getAgentToolRestrictions } from "../../../shared"
|
||||
import { setSessionTools } from "../../../shared/session-tools-store"
|
||||
import type { SpawnerContext } from "./spawner-context"
|
||||
import { subagentSessions } from "../../claude-code-session-state"
|
||||
import { getTaskToastManager } from "../../task-toast-manager"
|
||||
@@ -35,6 +36,9 @@ export async function resumeTask(
|
||||
task.parentMessageID = input.parentMessageID
|
||||
task.parentModel = input.parentModel
|
||||
task.parentAgent = input.parentAgent
|
||||
if (input.parentTools) {
|
||||
task.parentTools = input.parentTools
|
||||
}
|
||||
task.startedAt = new Date()
|
||||
|
||||
task.progress = {
|
||||
@@ -75,12 +79,16 @@ export async function resumeTask(
|
||||
agent: task.agent,
|
||||
...(resumeModel ? { model: resumeModel } : {}),
|
||||
...(resumeVariant ? { variant: resumeVariant } : {}),
|
||||
tools: {
|
||||
...getAgentToolRestrictions(task.agent),
|
||||
task: false,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
},
|
||||
tools: (() => {
|
||||
const tools = {
|
||||
...getAgentToolRestrictions(task.agent),
|
||||
task: false,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
}
|
||||
setSessionTools(task.sessionID!, tools)
|
||||
return tools
|
||||
})(),
|
||||
parts: [{ type: "text", text: input.prompt }],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { QueueItem } from "../constants"
|
||||
import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry } from "../../../shared"
|
||||
import { setSessionTools } from "../../../shared/session-tools-store"
|
||||
import { subagentSessions } from "../../claude-code-session-state"
|
||||
import { getTaskToastManager } from "../../task-toast-manager"
|
||||
import { createBackgroundSession } from "./background-session-creator"
|
||||
@@ -79,12 +80,16 @@ export async function startTask(item: QueueItem, ctx: SpawnerContext): Promise<v
|
||||
...(launchModel ? { model: launchModel } : {}),
|
||||
...(launchVariant ? { variant: launchVariant } : {}),
|
||||
system: input.skillContent,
|
||||
tools: {
|
||||
...getAgentToolRestrictions(input.agent),
|
||||
task: false,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
},
|
||||
tools: (() => {
|
||||
const tools = {
|
||||
...getAgentToolRestrictions(input.agent),
|
||||
task: false,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
}
|
||||
setSessionTools(sessionID, tools)
|
||||
return tools
|
||||
})(),
|
||||
parts: [{ type: "text", text: input.prompt }],
|
||||
},
|
||||
}).catch((error: unknown) => {
|
||||
|
||||
425
src/features/background-agent/task-poller.test.ts
Normal file
425
src/features/background-agent/task-poller.test.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
import { describe, it, expect, mock } from "bun:test"
|
||||
|
||||
import { checkAndInterruptStaleTasks, pruneStaleTasksAndNotifications } from "./task-poller"
|
||||
import type { BackgroundTask } from "./types"
|
||||
|
||||
describe("checkAndInterruptStaleTasks", () => {
|
||||
const mockClient = {
|
||||
session: {
|
||||
abort: mock(() => Promise.resolve()),
|
||||
},
|
||||
}
|
||||
const mockConcurrencyManager = {
|
||||
release: mock(() => {}),
|
||||
}
|
||||
const mockNotify = mock(() => Promise.resolve())
|
||||
|
||||
function createRunningTask(overrides: Partial<BackgroundTask> = {}): BackgroundTask {
|
||||
return {
|
||||
id: "task-1",
|
||||
sessionID: "ses-1",
|
||||
parentSessionID: "parent-ses-1",
|
||||
parentMessageID: "msg-1",
|
||||
description: "test",
|
||||
prompt: "test",
|
||||
agent: "explore",
|
||||
status: "running",
|
||||
startedAt: new Date(Date.now() - 120_000),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
it("should interrupt tasks with lastUpdate exceeding stale timeout", async () => {
|
||||
//#given
|
||||
const task = createRunningTask({
|
||||
progress: {
|
||||
toolCalls: 1,
|
||||
lastUpdate: new Date(Date.now() - 200_000),
|
||||
},
|
||||
})
|
||||
|
||||
//#when
|
||||
await checkAndInterruptStaleTasks({
|
||||
tasks: [task],
|
||||
client: mockClient as never,
|
||||
config: { staleTimeoutMs: 180_000 },
|
||||
concurrencyManager: mockConcurrencyManager as never,
|
||||
notifyParentSession: mockNotify,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(task.status).toBe("cancelled")
|
||||
expect(task.error).toContain("Stale timeout")
|
||||
})
|
||||
|
||||
it("should NOT interrupt tasks with recent lastUpdate", async () => {
|
||||
//#given
|
||||
const task = createRunningTask({
|
||||
progress: {
|
||||
toolCalls: 1,
|
||||
lastUpdate: new Date(Date.now() - 10_000),
|
||||
},
|
||||
})
|
||||
|
||||
//#when
|
||||
await checkAndInterruptStaleTasks({
|
||||
tasks: [task],
|
||||
client: mockClient as never,
|
||||
config: { staleTimeoutMs: 180_000 },
|
||||
concurrencyManager: mockConcurrencyManager as never,
|
||||
notifyParentSession: mockNotify,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(task.status).toBe("running")
|
||||
})
|
||||
|
||||
it("should interrupt tasks with NO progress.lastUpdate that exceeded messageStalenessTimeoutMs since startedAt", async () => {
|
||||
//#given — task started 15 minutes ago, never received any progress update
|
||||
const task = createRunningTask({
|
||||
startedAt: new Date(Date.now() - 15 * 60 * 1000),
|
||||
progress: undefined,
|
||||
})
|
||||
|
||||
//#when
|
||||
await checkAndInterruptStaleTasks({
|
||||
tasks: [task],
|
||||
client: mockClient as never,
|
||||
config: { messageStalenessTimeoutMs: 600_000 },
|
||||
concurrencyManager: mockConcurrencyManager as never,
|
||||
notifyParentSession: mockNotify,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(task.status).toBe("cancelled")
|
||||
expect(task.error).toContain("no activity")
|
||||
})
|
||||
|
||||
it("should NOT interrupt tasks with NO progress.lastUpdate that are within messageStalenessTimeoutMs", async () => {
|
||||
//#given — task started 5 minutes ago, default timeout is 10 minutes
|
||||
const task = createRunningTask({
|
||||
startedAt: new Date(Date.now() - 5 * 60 * 1000),
|
||||
progress: undefined,
|
||||
})
|
||||
|
||||
//#when
|
||||
await checkAndInterruptStaleTasks({
|
||||
tasks: [task],
|
||||
client: mockClient as never,
|
||||
config: { messageStalenessTimeoutMs: 600_000 },
|
||||
concurrencyManager: mockConcurrencyManager as never,
|
||||
notifyParentSession: mockNotify,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(task.status).toBe("running")
|
||||
})
|
||||
|
||||
it("should use DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS when messageStalenessTimeoutMs is not configured", async () => {
|
||||
//#given — task started 15 minutes ago, no config for messageStalenessTimeoutMs
|
||||
const task = createRunningTask({
|
||||
startedAt: new Date(Date.now() - 15 * 60 * 1000),
|
||||
progress: undefined,
|
||||
})
|
||||
|
||||
//#when — default is 10 minutes (600_000ms)
|
||||
await checkAndInterruptStaleTasks({
|
||||
tasks: [task],
|
||||
client: mockClient as never,
|
||||
config: undefined,
|
||||
concurrencyManager: mockConcurrencyManager as never,
|
||||
notifyParentSession: mockNotify,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(task.status).toBe("cancelled")
|
||||
expect(task.error).toContain("no activity")
|
||||
})
|
||||
|
||||
it("should NOT interrupt task when session is running, even if lastUpdate exceeds stale timeout", async () => {
|
||||
//#given — lastUpdate is 5min old but session is actively running
|
||||
const task = createRunningTask({
|
||||
startedAt: new Date(Date.now() - 300_000),
|
||||
progress: {
|
||||
toolCalls: 2,
|
||||
lastUpdate: new Date(Date.now() - 300_000),
|
||||
},
|
||||
})
|
||||
|
||||
//#when — session status is "busy" (OpenCode's actual status for active LLM processing)
|
||||
await checkAndInterruptStaleTasks({
|
||||
tasks: [task],
|
||||
client: mockClient as never,
|
||||
config: { staleTimeoutMs: 180_000 },
|
||||
concurrencyManager: mockConcurrencyManager as never,
|
||||
notifyParentSession: mockNotify,
|
||||
sessionStatuses: { "ses-1": { type: "busy" } },
|
||||
})
|
||||
|
||||
//#then — task should survive because session is actively busy
|
||||
expect(task.status).toBe("running")
|
||||
})
|
||||
|
||||
it("should NOT interrupt busy session task even with very old lastUpdate", async () => {
|
||||
//#given — lastUpdate is 15min old, but session is still busy
|
||||
const task = createRunningTask({
|
||||
startedAt: new Date(Date.now() - 900_000),
|
||||
progress: {
|
||||
toolCalls: 2,
|
||||
lastUpdate: new Date(Date.now() - 900_000),
|
||||
},
|
||||
})
|
||||
|
||||
//#when — session busy, lastUpdate far exceeds any timeout
|
||||
await checkAndInterruptStaleTasks({
|
||||
tasks: [task],
|
||||
client: mockClient as never,
|
||||
config: { staleTimeoutMs: 180_000, messageStalenessTimeoutMs: 600_000 },
|
||||
concurrencyManager: mockConcurrencyManager as never,
|
||||
notifyParentSession: mockNotify,
|
||||
sessionStatuses: { "ses-1": { type: "busy" } },
|
||||
})
|
||||
|
||||
//#then — busy sessions are NEVER stale-killed (babysitter + TTL prune handle these)
|
||||
expect(task.status).toBe("running")
|
||||
})
|
||||
|
||||
it("should NOT interrupt busy session even with no progress (undefined lastUpdate)", async () => {
|
||||
//#given — task has no progress at all, but session is busy
|
||||
const task = createRunningTask({
|
||||
startedAt: new Date(Date.now() - 15 * 60 * 1000),
|
||||
progress: undefined,
|
||||
})
|
||||
|
||||
//#when — session is busy
|
||||
await checkAndInterruptStaleTasks({
|
||||
tasks: [task],
|
||||
client: mockClient as never,
|
||||
config: { messageStalenessTimeoutMs: 600_000 },
|
||||
concurrencyManager: mockConcurrencyManager as never,
|
||||
notifyParentSession: mockNotify,
|
||||
sessionStatuses: { "ses-1": { type: "busy" } },
|
||||
})
|
||||
|
||||
//#then — task should survive because session is actively running
|
||||
expect(task.status).toBe("running")
|
||||
})
|
||||
|
||||
it("should interrupt task when session is idle and lastUpdate exceeds stale timeout", async () => {
|
||||
//#given — lastUpdate is 5min old and session is idle
|
||||
const task = createRunningTask({
|
||||
startedAt: new Date(Date.now() - 300_000),
|
||||
progress: {
|
||||
toolCalls: 2,
|
||||
lastUpdate: new Date(Date.now() - 300_000),
|
||||
},
|
||||
})
|
||||
|
||||
//#when — session status is "idle"
|
||||
await checkAndInterruptStaleTasks({
|
||||
tasks: [task],
|
||||
client: mockClient as never,
|
||||
config: { staleTimeoutMs: 180_000 },
|
||||
concurrencyManager: mockConcurrencyManager as never,
|
||||
notifyParentSession: mockNotify,
|
||||
sessionStatuses: { "ses-1": { type: "idle" } },
|
||||
})
|
||||
|
||||
//#then — task should be killed because session is idle with stale lastUpdate
|
||||
expect(task.status).toBe("cancelled")
|
||||
expect(task.error).toContain("Stale timeout")
|
||||
})
|
||||
|
||||
it("should NOT interrupt running session task even with very old lastUpdate", async () => {
|
||||
//#given — lastUpdate is 15min old, but session is still running
|
||||
const task = createRunningTask({
|
||||
startedAt: new Date(Date.now() - 900_000),
|
||||
progress: {
|
||||
toolCalls: 2,
|
||||
lastUpdate: new Date(Date.now() - 900_000),
|
||||
},
|
||||
})
|
||||
|
||||
//#when — session running, lastUpdate far exceeds any timeout
|
||||
await checkAndInterruptStaleTasks({
|
||||
tasks: [task],
|
||||
client: mockClient as never,
|
||||
config: { staleTimeoutMs: 180_000, messageStalenessTimeoutMs: 600_000 },
|
||||
concurrencyManager: mockConcurrencyManager as never,
|
||||
notifyParentSession: mockNotify,
|
||||
sessionStatuses: { "ses-1": { type: "running" } },
|
||||
})
|
||||
|
||||
//#then — running sessions are NEVER stale-killed (babysitter + TTL prune handle these)
|
||||
expect(task.status).toBe("running")
|
||||
})
|
||||
|
||||
it("should NOT interrupt running session even with no progress (undefined lastUpdate)", async () => {
|
||||
//#given — task has no progress at all, but session is running
|
||||
const task = createRunningTask({
|
||||
startedAt: new Date(Date.now() - 15 * 60 * 1000),
|
||||
progress: undefined,
|
||||
})
|
||||
|
||||
//#when — session is running
|
||||
await checkAndInterruptStaleTasks({
|
||||
tasks: [task],
|
||||
client: mockClient as never,
|
||||
config: { messageStalenessTimeoutMs: 600_000 },
|
||||
concurrencyManager: mockConcurrencyManager as never,
|
||||
notifyParentSession: mockNotify,
|
||||
sessionStatuses: { "ses-1": { type: "running" } },
|
||||
})
|
||||
|
||||
//#then — running sessions are NEVER killed, even without progress
|
||||
expect(task.status).toBe("running")
|
||||
})
|
||||
|
||||
it("should use default stale timeout when session status is unknown/missing", async () => {
|
||||
//#given — lastUpdate exceeds stale timeout, session not in status map
|
||||
const task = createRunningTask({
|
||||
startedAt: new Date(Date.now() - 300_000),
|
||||
progress: {
|
||||
toolCalls: 1,
|
||||
lastUpdate: new Date(Date.now() - 200_000),
|
||||
},
|
||||
})
|
||||
|
||||
//#when — empty sessionStatuses (session not found)
|
||||
await checkAndInterruptStaleTasks({
|
||||
tasks: [task],
|
||||
client: mockClient as never,
|
||||
config: { staleTimeoutMs: 180_000 },
|
||||
concurrencyManager: mockConcurrencyManager as never,
|
||||
notifyParentSession: mockNotify,
|
||||
sessionStatuses: {},
|
||||
})
|
||||
|
||||
//#then — unknown session treated as potentially stale, apply default timeout
|
||||
expect(task.status).toBe("cancelled")
|
||||
expect(task.error).toContain("Stale timeout")
|
||||
})
|
||||
|
||||
it("should NOT interrupt task when session is busy (OpenCode status), even if lastUpdate exceeds stale timeout", async () => {
|
||||
//#given — lastUpdate is 5min old but session is "busy" (OpenCode's actual status for active sessions)
|
||||
const task = createRunningTask({
|
||||
startedAt: new Date(Date.now() - 300_000),
|
||||
progress: {
|
||||
toolCalls: 2,
|
||||
lastUpdate: new Date(Date.now() - 300_000),
|
||||
},
|
||||
})
|
||||
|
||||
//#when — session status is "busy" (not "running" — OpenCode uses "busy" for active LLM processing)
|
||||
await checkAndInterruptStaleTasks({
|
||||
tasks: [task],
|
||||
client: mockClient as never,
|
||||
config: { staleTimeoutMs: 180_000 },
|
||||
concurrencyManager: mockConcurrencyManager as never,
|
||||
notifyParentSession: mockNotify,
|
||||
sessionStatuses: { "ses-1": { type: "busy" } },
|
||||
})
|
||||
|
||||
//#then — "busy" sessions must be protected from stale-kill
|
||||
expect(task.status).toBe("running")
|
||||
})
|
||||
|
||||
it("should NOT interrupt task when session is in retry state", async () => {
|
||||
//#given — lastUpdate is 5min old but session is retrying
|
||||
const task = createRunningTask({
|
||||
startedAt: new Date(Date.now() - 300_000),
|
||||
progress: {
|
||||
toolCalls: 1,
|
||||
lastUpdate: new Date(Date.now() - 300_000),
|
||||
},
|
||||
})
|
||||
|
||||
//#when — session status is "retry" (OpenCode retries on transient API errors)
|
||||
await checkAndInterruptStaleTasks({
|
||||
tasks: [task],
|
||||
client: mockClient as never,
|
||||
config: { staleTimeoutMs: 180_000 },
|
||||
concurrencyManager: mockConcurrencyManager as never,
|
||||
notifyParentSession: mockNotify,
|
||||
sessionStatuses: { "ses-1": { type: "retry" } },
|
||||
})
|
||||
|
||||
//#then — retry sessions must be protected from stale-kill
|
||||
expect(task.status).toBe("running")
|
||||
})
|
||||
|
||||
it("should NOT interrupt busy session even with no progress (undefined lastUpdate)", async () => {
|
||||
//#given — no progress at all, session is "busy" (thinking model with no streamed tokens yet)
|
||||
const task = createRunningTask({
|
||||
startedAt: new Date(Date.now() - 15 * 60 * 1000),
|
||||
progress: undefined,
|
||||
})
|
||||
|
||||
//#when — session is busy
|
||||
await checkAndInterruptStaleTasks({
|
||||
tasks: [task],
|
||||
client: mockClient as never,
|
||||
config: { messageStalenessTimeoutMs: 600_000 },
|
||||
concurrencyManager: mockConcurrencyManager as never,
|
||||
notifyParentSession: mockNotify,
|
||||
sessionStatuses: { "ses-1": { type: "busy" } },
|
||||
})
|
||||
|
||||
//#then — busy sessions with no progress must survive
|
||||
expect(task.status).toBe("running")
|
||||
})
|
||||
|
||||
it("should release concurrency key when interrupting a never-updated task", async () => {
|
||||
//#given
|
||||
const releaseMock = mock(() => {})
|
||||
const task = createRunningTask({
|
||||
startedAt: new Date(Date.now() - 15 * 60 * 1000),
|
||||
progress: undefined,
|
||||
concurrencyKey: "anthropic/claude-opus-4-6",
|
||||
})
|
||||
|
||||
//#when
|
||||
await checkAndInterruptStaleTasks({
|
||||
tasks: [task],
|
||||
client: mockClient as never,
|
||||
config: { messageStalenessTimeoutMs: 600_000 },
|
||||
concurrencyManager: { release: releaseMock } as never,
|
||||
notifyParentSession: mockNotify,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(releaseMock).toHaveBeenCalledWith("anthropic/claude-opus-4-6")
|
||||
expect(task.concurrencyKey).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("pruneStaleTasksAndNotifications", () => {
|
||||
it("should prune tasks that exceeded TTL", () => {
|
||||
//#given
|
||||
const tasks = new Map<string, BackgroundTask>()
|
||||
const oldTask: BackgroundTask = {
|
||||
id: "old-task",
|
||||
parentSessionID: "parent",
|
||||
parentMessageID: "msg",
|
||||
description: "old",
|
||||
prompt: "old",
|
||||
agent: "explore",
|
||||
status: "running",
|
||||
startedAt: new Date(Date.now() - 31 * 60 * 1000),
|
||||
}
|
||||
tasks.set("old-task", oldTask)
|
||||
|
||||
const pruned: string[] = []
|
||||
const notifications = new Map<string, BackgroundTask[]>()
|
||||
|
||||
//#when
|
||||
pruneStaleTasksAndNotifications({
|
||||
tasks,
|
||||
notifications,
|
||||
onTaskPruned: (taskId) => pruned.push(taskId),
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(pruned).toContain("old-task")
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import type { ConcurrencyManager } from "./concurrency"
|
||||
import type { OpencodeClient } from "./opencode-client"
|
||||
|
||||
import {
|
||||
DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS,
|
||||
DEFAULT_STALE_TIMEOUT_MS,
|
||||
MIN_RUNTIME_BEFORE_STALE_MS,
|
||||
TASK_TTL_MS,
|
||||
@@ -56,26 +57,60 @@ export function pruneStaleTasksAndNotifications(args: {
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionStatusMap = Record<string, { type: string }>
|
||||
|
||||
export async function checkAndInterruptStaleTasks(args: {
|
||||
tasks: Iterable<BackgroundTask>
|
||||
client: OpencodeClient
|
||||
config: BackgroundTaskConfig | undefined
|
||||
concurrencyManager: ConcurrencyManager
|
||||
notifyParentSession: (task: BackgroundTask) => Promise<void>
|
||||
sessionStatuses?: SessionStatusMap
|
||||
}): Promise<void> {
|
||||
const { tasks, client, config, concurrencyManager, notifyParentSession } = args
|
||||
const { tasks, client, config, concurrencyManager, notifyParentSession, sessionStatuses } = args
|
||||
const staleTimeoutMs = config?.staleTimeoutMs ?? DEFAULT_STALE_TIMEOUT_MS
|
||||
const now = Date.now()
|
||||
|
||||
const messageStalenessMs = config?.messageStalenessTimeoutMs ?? DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS
|
||||
|
||||
for (const task of tasks) {
|
||||
if (task.status !== "running") continue
|
||||
if (!task.progress?.lastUpdate) continue
|
||||
|
||||
const startedAt = task.startedAt
|
||||
const sessionID = task.sessionID
|
||||
if (!startedAt || !sessionID) continue
|
||||
|
||||
const sessionStatus = sessionStatuses?.[sessionID]?.type
|
||||
const sessionIsRunning = sessionStatus !== undefined && sessionStatus !== "idle"
|
||||
const runtime = now - startedAt.getTime()
|
||||
|
||||
if (!task.progress?.lastUpdate) {
|
||||
if (sessionIsRunning) continue
|
||||
if (runtime <= messageStalenessMs) continue
|
||||
|
||||
const staleMinutes = Math.round(runtime / 60000)
|
||||
task.status = "cancelled"
|
||||
task.error = `Stale timeout (no activity for ${staleMinutes}min since start)`
|
||||
task.completedAt = new Date()
|
||||
|
||||
if (task.concurrencyKey) {
|
||||
concurrencyManager.release(task.concurrencyKey)
|
||||
task.concurrencyKey = undefined
|
||||
}
|
||||
|
||||
client.session.abort({ path: { id: sessionID } }).catch(() => {})
|
||||
log(`[background-agent] Task ${task.id} interrupted: no progress since start`)
|
||||
|
||||
try {
|
||||
await notifyParentSession(task)
|
||||
} catch (err) {
|
||||
log("[background-agent] Error in notifyParentSession for stale task:", { taskId: task.id, error: err })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (sessionIsRunning) continue
|
||||
|
||||
if (runtime < MIN_RUNTIME_BEFORE_STALE_MS) continue
|
||||
|
||||
const timeSinceLastUpdate = now - task.progress.lastUpdate.getTime()
|
||||
@@ -92,10 +127,7 @@ export async function checkAndInterruptStaleTasks(args: {
|
||||
task.concurrencyKey = undefined
|
||||
}
|
||||
|
||||
client.session.abort({
|
||||
path: { id: sessionID },
|
||||
}).catch(() => {})
|
||||
|
||||
client.session.abort({ path: { id: sessionID } }).catch(() => {})
|
||||
log(`[background-agent] Task ${task.id} interrupted: stale timeout`)
|
||||
|
||||
try {
|
||||
|
||||
@@ -37,6 +37,8 @@ export interface BackgroundTask {
|
||||
concurrencyGroup?: string
|
||||
/** Parent session's agent name for notification */
|
||||
parentAgent?: string
|
||||
/** Parent session's tool restrictions for notification prompts */
|
||||
parentTools?: Record<string, boolean>
|
||||
/** Marks if the task was launched from an unstable agent/category */
|
||||
isUnstableAgent?: boolean
|
||||
/** Category used for this task (e.g., 'quick', 'visual-engineering') */
|
||||
@@ -56,6 +58,7 @@ export interface LaunchInput {
|
||||
parentMessageID: string
|
||||
parentModel?: { providerID: string; modelID: string }
|
||||
parentAgent?: string
|
||||
parentTools?: Record<string, boolean>
|
||||
model?: { providerID: string; modelID: string; variant?: string }
|
||||
isUnstableAgent?: boolean
|
||||
skills?: string[]
|
||||
@@ -70,4 +73,5 @@ export interface ResumeInput {
|
||||
parentMessageID: string
|
||||
parentModel?: { providerID: string; modelID: string }
|
||||
parentAgent?: string
|
||||
parentTools?: Record<string, boolean>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test"
|
||||
import { mkdirSync, writeFileSync, rmSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { tmpdir } from "os"
|
||||
|
||||
const TEST_DIR = join(tmpdir(), "agents-global-skills-test-" + Date.now())
|
||||
const TEMP_HOME = join(TEST_DIR, "home")
|
||||
|
||||
describe("discoverGlobalAgentsSkills", () => {
|
||||
beforeEach(() => {
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
mkdirSync(TEMP_HOME, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("#given a skill in ~/.agents/skills/ #when discoverGlobalAgentsSkills is called #then it discovers the skill", async () => {
|
||||
//#given
|
||||
const skillContent = `---
|
||||
name: agent-global-skill
|
||||
description: A skill from global .agents/skills directory
|
||||
---
|
||||
Skill body.
|
||||
`
|
||||
const agentsGlobalSkillsDir = join(TEMP_HOME, ".agents", "skills")
|
||||
const skillDir = join(agentsGlobalSkillsDir, "agent-global-skill")
|
||||
mkdirSync(skillDir, { recursive: true })
|
||||
writeFileSync(join(skillDir, "SKILL.md"), skillContent)
|
||||
|
||||
mock.module("os", () => ({
|
||||
homedir: () => TEMP_HOME,
|
||||
tmpdir,
|
||||
}))
|
||||
|
||||
//#when
|
||||
const { discoverGlobalAgentsSkills } = await import("./loader")
|
||||
const skills = await discoverGlobalAgentsSkills()
|
||||
const skill = skills.find(s => s.name === "agent-global-skill")
|
||||
|
||||
//#then
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill?.scope).toBe("user")
|
||||
expect(skill?.definition.description).toContain("A skill from global .agents/skills directory")
|
||||
})
|
||||
})
|
||||
@@ -552,7 +552,7 @@ Skill body.
|
||||
expect(names.length).toBe(uniqueNames.length)
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
if (originalOpenCodeConfigDir === undefined) {
|
||||
if (originalOpenCodeConfigDir === undefined) {
|
||||
delete process.env.OPENCODE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.OPENCODE_CONFIG_DIR = originalOpenCodeConfigDir
|
||||
@@ -560,4 +560,60 @@ Skill body.
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("agents skills discovery (.agents/skills/)", () => {
|
||||
it("#given a skill in .agents/skills/ #when discoverProjectAgentsSkills is called #then it discovers the skill", async () => {
|
||||
//#given
|
||||
const skillContent = `---
|
||||
name: agent-project-skill
|
||||
description: A skill from project .agents/skills directory
|
||||
---
|
||||
Skill body.
|
||||
`
|
||||
const agentsProjectSkillsDir = join(TEST_DIR, ".agents", "skills")
|
||||
const skillDir = join(agentsProjectSkillsDir, "agent-project-skill")
|
||||
mkdirSync(skillDir, { recursive: true })
|
||||
writeFileSync(join(skillDir, "SKILL.md"), skillContent)
|
||||
|
||||
//#when
|
||||
const { discoverProjectAgentsSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = await discoverProjectAgentsSkills()
|
||||
const skill = skills.find(s => s.name === "agent-project-skill")
|
||||
|
||||
//#then
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill?.scope).toBe("project")
|
||||
expect(skill?.definition.description).toContain("A skill from project .agents/skills directory")
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
|
||||
it("#given a skill in .agents/skills/ #when discoverProjectAgentsSkills is called with directory #then it discovers the skill", async () => {
|
||||
//#given
|
||||
const skillContent = `---
|
||||
name: agent-dir-skill
|
||||
description: A skill via explicit directory param
|
||||
---
|
||||
Skill body.
|
||||
`
|
||||
const agentsProjectSkillsDir = join(TEST_DIR, ".agents", "skills")
|
||||
const skillDir = join(agentsProjectSkillsDir, "agent-dir-skill")
|
||||
mkdirSync(skillDir, { recursive: true })
|
||||
writeFileSync(join(skillDir, "SKILL.md"), skillContent)
|
||||
|
||||
//#when
|
||||
const { discoverProjectAgentsSkills } = await import("./loader")
|
||||
const skills = await discoverProjectAgentsSkills(TEST_DIR)
|
||||
const skill = skills.find(s => s.name === "agent-dir-skill")
|
||||
|
||||
//#then
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill?.scope).toBe("project")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { join } from "path"
|
||||
import { homedir } from "os"
|
||||
import { getClaudeConfigDir } from "../../shared/claude-config-dir"
|
||||
import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir"
|
||||
import type { CommandDefinition } from "../claude-code-command-loader/types"
|
||||
@@ -38,15 +39,25 @@ export interface DiscoverSkillsOptions {
|
||||
}
|
||||
|
||||
export async function discoverAllSkills(directory?: string): Promise<LoadedSkill[]> {
|
||||
const [opencodeProjectSkills, opencodeGlobalSkills, projectSkills, userSkills] = await Promise.all([
|
||||
discoverOpencodeProjectSkills(directory),
|
||||
discoverOpencodeGlobalSkills(),
|
||||
discoverProjectClaudeSkills(directory),
|
||||
discoverUserClaudeSkills(),
|
||||
])
|
||||
const [opencodeProjectSkills, opencodeGlobalSkills, projectSkills, userSkills, agentsProjectSkills, agentsGlobalSkills] =
|
||||
await Promise.all([
|
||||
discoverOpencodeProjectSkills(directory),
|
||||
discoverOpencodeGlobalSkills(),
|
||||
discoverProjectClaudeSkills(directory),
|
||||
discoverUserClaudeSkills(),
|
||||
discoverProjectAgentsSkills(directory),
|
||||
discoverGlobalAgentsSkills(),
|
||||
])
|
||||
|
||||
// Priority: opencode-project > opencode > project > user
|
||||
return deduplicateSkillsByName([...opencodeProjectSkills, ...opencodeGlobalSkills, ...projectSkills, ...userSkills])
|
||||
// Priority: opencode-project > opencode > project (.claude + .agents) > user (.claude + .agents)
|
||||
return deduplicateSkillsByName([
|
||||
...opencodeProjectSkills,
|
||||
...opencodeGlobalSkills,
|
||||
...projectSkills,
|
||||
...agentsProjectSkills,
|
||||
...userSkills,
|
||||
...agentsGlobalSkills,
|
||||
])
|
||||
}
|
||||
|
||||
export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promise<LoadedSkill[]> {
|
||||
@@ -62,13 +73,22 @@ export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promi
|
||||
return deduplicateSkillsByName([...opencodeProjectSkills, ...opencodeGlobalSkills])
|
||||
}
|
||||
|
||||
const [projectSkills, userSkills] = await Promise.all([
|
||||
const [projectSkills, userSkills, agentsProjectSkills, agentsGlobalSkills] = await Promise.all([
|
||||
discoverProjectClaudeSkills(directory),
|
||||
discoverUserClaudeSkills(),
|
||||
discoverProjectAgentsSkills(directory),
|
||||
discoverGlobalAgentsSkills(),
|
||||
])
|
||||
|
||||
// Priority: opencode-project > opencode > project > user
|
||||
return deduplicateSkillsByName([...opencodeProjectSkills, ...opencodeGlobalSkills, ...projectSkills, ...userSkills])
|
||||
// Priority: opencode-project > opencode > project (.claude + .agents) > user (.claude + .agents)
|
||||
return deduplicateSkillsByName([
|
||||
...opencodeProjectSkills,
|
||||
...opencodeGlobalSkills,
|
||||
...projectSkills,
|
||||
...agentsProjectSkills,
|
||||
...userSkills,
|
||||
...agentsGlobalSkills,
|
||||
])
|
||||
}
|
||||
|
||||
export async function getSkillByName(name: string, options: DiscoverSkillsOptions = {}): Promise<LoadedSkill | undefined> {
|
||||
@@ -96,3 +116,13 @@ export async function discoverOpencodeProjectSkills(directory?: string): Promise
|
||||
const opencodeProjectDir = join(directory ?? process.cwd(), ".opencode", "skills")
|
||||
return loadSkillsFromDir({ skillsDir: opencodeProjectDir, scope: "opencode-project" })
|
||||
}
|
||||
|
||||
export async function discoverProjectAgentsSkills(directory?: string): Promise<LoadedSkill[]> {
|
||||
const agentsProjectDir = join(directory ?? process.cwd(), ".agents", "skills")
|
||||
return loadSkillsFromDir({ skillsDir: agentsProjectDir, scope: "project" })
|
||||
}
|
||||
|
||||
export async function discoverGlobalAgentsSkills(): Promise<LoadedSkill[]> {
|
||||
const agentsGlobalDir = join(homedir(), ".agents", "skills")
|
||||
return loadSkillsFromDir({ skillsDir: agentsGlobalDir, scope: "user" })
|
||||
}
|
||||
|
||||
@@ -351,4 +351,47 @@ describe("calculateCapacity", () => {
|
||||
expect(capacity.rows).toBe(4)
|
||||
expect(capacity.total).toBe(12)
|
||||
})
|
||||
|
||||
it("#given a smaller minPaneWidth #when calculating capacity #then fits more columns", () => {
|
||||
//#given
|
||||
const smallMinWidth = 30
|
||||
|
||||
//#when
|
||||
const defaultCapacity = calculateCapacity(212, 44)
|
||||
const customCapacity = calculateCapacity(212, 44, smallMinWidth)
|
||||
|
||||
//#then
|
||||
expect(customCapacity.cols).toBeGreaterThanOrEqual(defaultCapacity.cols)
|
||||
})
|
||||
})
|
||||
|
||||
describe("decideSpawnActions with custom agentPaneWidth", () => {
|
||||
const createWindowState = (
|
||||
windowWidth: number,
|
||||
windowHeight: number,
|
||||
agentPanes: Array<{ paneId: string; width: number; height: number; left: number; top: number }> = []
|
||||
): WindowState => ({
|
||||
windowWidth,
|
||||
windowHeight,
|
||||
mainPane: { paneId: "%0", width: Math.floor(windowWidth / 2), height: windowHeight, left: 0, top: 0, title: "main", isActive: true },
|
||||
agentPanes: agentPanes.map((p, i) => ({
|
||||
...p,
|
||||
title: `agent-${i}`,
|
||||
isActive: false,
|
||||
})),
|
||||
})
|
||||
|
||||
it("#given a smaller agentPaneWidth #when window would be too small for default #then spawns with custom config", () => {
|
||||
//#given
|
||||
const smallConfig: CapacityConfig = { mainPaneMinWidth: 120, agentPaneWidth: 25 }
|
||||
const state = createWindowState(100, 30)
|
||||
|
||||
//#when
|
||||
const defaultResult = decideSpawnActions(state, "ses1", "test", { mainPaneMinWidth: 120, agentPaneWidth: 52 }, [])
|
||||
const customResult = decideSpawnActions(state, "ses1", "test", smallConfig, [])
|
||||
|
||||
//#then
|
||||
expect(defaultResult.canSpawn).toBe(false)
|
||||
expect(customResult.canSpawn).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { MIN_PANE_HEIGHT, MIN_PANE_WIDTH } from "./types"
|
||||
import type { TmuxPaneInfo } from "./types"
|
||||
import {
|
||||
DIVIDER_SIZE,
|
||||
MAIN_PANE_RATIO,
|
||||
MAX_GRID_SIZE,
|
||||
} from "./tmux-grid-constants"
|
||||
import { MIN_PANE_HEIGHT, MIN_PANE_WIDTH } from "./types"
|
||||
|
||||
export interface GridCapacity {
|
||||
cols: number
|
||||
@@ -27,6 +27,7 @@ export interface GridPlan {
|
||||
export function calculateCapacity(
|
||||
windowWidth: number,
|
||||
windowHeight: number,
|
||||
minPaneWidth: number = MIN_PANE_WIDTH,
|
||||
): GridCapacity {
|
||||
const availableWidth = Math.floor(windowWidth * (1 - MAIN_PANE_RATIO))
|
||||
const cols = Math.min(
|
||||
@@ -34,7 +35,7 @@ export function calculateCapacity(
|
||||
Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
(availableWidth + DIVIDER_SIZE) / (MIN_PANE_WIDTH + DIVIDER_SIZE),
|
||||
(availableWidth + DIVIDER_SIZE) / (minPaneWidth + DIVIDER_SIZE),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MIN_PANE_HEIGHT, MIN_PANE_WIDTH } from "./types"
|
||||
import type { SplitDirection, TmuxPaneInfo } from "./types"
|
||||
import {
|
||||
DIVIDER_SIZE,
|
||||
@@ -7,6 +8,10 @@ import {
|
||||
MIN_SPLIT_WIDTH,
|
||||
} from "./tmux-grid-constants"
|
||||
|
||||
function minSplitWidthFor(minPaneWidth: number): number {
|
||||
return 2 * minPaneWidth + DIVIDER_SIZE
|
||||
}
|
||||
|
||||
export function getColumnCount(paneCount: number): number {
|
||||
if (paneCount <= 0) return 1
|
||||
return Math.min(MAX_COLS, Math.max(1, Math.ceil(paneCount / MAX_ROWS)))
|
||||
@@ -21,26 +26,32 @@ export function getColumnWidth(agentAreaWidth: number, paneCount: number): numbe
|
||||
export function isSplittableAtCount(
|
||||
agentAreaWidth: number,
|
||||
paneCount: number,
|
||||
minPaneWidth: number = MIN_PANE_WIDTH,
|
||||
): boolean {
|
||||
const columnWidth = getColumnWidth(agentAreaWidth, paneCount)
|
||||
return columnWidth >= MIN_SPLIT_WIDTH
|
||||
return columnWidth >= minSplitWidthFor(minPaneWidth)
|
||||
}
|
||||
|
||||
export function findMinimalEvictions(
|
||||
agentAreaWidth: number,
|
||||
currentCount: number,
|
||||
minPaneWidth: number = MIN_PANE_WIDTH,
|
||||
): number | null {
|
||||
for (let k = 1; k <= currentCount; k++) {
|
||||
if (isSplittableAtCount(agentAreaWidth, currentCount - k)) {
|
||||
if (isSplittableAtCount(agentAreaWidth, currentCount - k, minPaneWidth)) {
|
||||
return k
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function canSplitPane(pane: TmuxPaneInfo, direction: SplitDirection): boolean {
|
||||
export function canSplitPane(
|
||||
pane: TmuxPaneInfo,
|
||||
direction: SplitDirection,
|
||||
minPaneWidth: number = MIN_PANE_WIDTH,
|
||||
): boolean {
|
||||
if (direction === "-h") {
|
||||
return pane.width >= MIN_SPLIT_WIDTH
|
||||
return pane.width >= minSplitWidthFor(minPaneWidth)
|
||||
}
|
||||
return pane.height >= MIN_SPLIT_HEIGHT
|
||||
}
|
||||
|
||||
@@ -13,23 +13,23 @@ import {
|
||||
} from "./pane-split-availability"
|
||||
import { findSpawnTarget } from "./spawn-target-finder"
|
||||
import { findOldestAgentPane, type SessionMapping } from "./oldest-agent-pane"
|
||||
import { MIN_PANE_WIDTH } from "./types"
|
||||
|
||||
export function decideSpawnActions(
|
||||
state: WindowState,
|
||||
sessionId: string,
|
||||
description: string,
|
||||
_config: CapacityConfig,
|
||||
config: CapacityConfig,
|
||||
sessionMappings: SessionMapping[],
|
||||
): SpawnDecision {
|
||||
if (!state.mainPane) {
|
||||
return { canSpawn: false, actions: [], reason: "no main pane found" }
|
||||
}
|
||||
|
||||
const minPaneWidth = config.agentPaneWidth
|
||||
const agentAreaWidth = Math.floor(state.windowWidth * (1 - MAIN_PANE_RATIO))
|
||||
const currentCount = state.agentPanes.length
|
||||
|
||||
if (agentAreaWidth < MIN_PANE_WIDTH) {
|
||||
if (agentAreaWidth < minPaneWidth) {
|
||||
return {
|
||||
canSpawn: false,
|
||||
actions: [],
|
||||
@@ -44,7 +44,7 @@ export function decideSpawnActions(
|
||||
|
||||
if (currentCount === 0) {
|
||||
const virtualMainPane: TmuxPaneInfo = { ...state.mainPane, width: state.windowWidth }
|
||||
if (canSplitPane(virtualMainPane, "-h")) {
|
||||
if (canSplitPane(virtualMainPane, "-h", minPaneWidth)) {
|
||||
return {
|
||||
canSpawn: true,
|
||||
actions: [
|
||||
@@ -61,7 +61,7 @@ export function decideSpawnActions(
|
||||
return { canSpawn: false, actions: [], reason: "mainPane too small to split" }
|
||||
}
|
||||
|
||||
if (isSplittableAtCount(agentAreaWidth, currentCount)) {
|
||||
if (isSplittableAtCount(agentAreaWidth, currentCount, minPaneWidth)) {
|
||||
const spawnTarget = findSpawnTarget(state)
|
||||
if (spawnTarget) {
|
||||
return {
|
||||
@@ -79,7 +79,7 @@ export function decideSpawnActions(
|
||||
}
|
||||
}
|
||||
|
||||
const minEvictions = findMinimalEvictions(agentAreaWidth, currentCount)
|
||||
const minEvictions = findMinimalEvictions(agentAreaWidth, currentCount, minPaneWidth)
|
||||
if (minEvictions === 1 && oldestPane) {
|
||||
return {
|
||||
canSpawn: true,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { parseAnthropicTokenLimitError } from "./parser"
|
||||
|
||||
describe("parseAnthropicTokenLimitError", () => {
|
||||
it("#given a standard token limit error string #when parsing #then extracts tokens", () => {
|
||||
//#given
|
||||
const error = "prompt is too long: 250000 tokens > 200000 maximum"
|
||||
|
||||
//#when
|
||||
const result = parseAnthropicTokenLimitError(error)
|
||||
|
||||
//#then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.currentTokens).toBe(250000)
|
||||
expect(result!.maxTokens).toBe(200000)
|
||||
})
|
||||
|
||||
it("#given a non-token-limit error #when parsing #then returns null", () => {
|
||||
//#given
|
||||
const error = { message: "internal server error" }
|
||||
|
||||
//#when
|
||||
const result = parseAnthropicTokenLimitError(error)
|
||||
|
||||
//#then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("#given null input #when parsing #then returns null", () => {
|
||||
//#given
|
||||
const error = null
|
||||
|
||||
//#when
|
||||
const result = parseAnthropicTokenLimitError(error)
|
||||
|
||||
//#then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("#given a proxy error with non-standard structure #when parsing #then returns null without crashing", () => {
|
||||
//#given
|
||||
const proxyError = {
|
||||
data: [1, 2, 3],
|
||||
error: "string-not-object",
|
||||
message: "Failed to process error response",
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = parseAnthropicTokenLimitError(proxyError)
|
||||
|
||||
//#then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("#given a circular reference error #when parsing #then returns null without crashing", () => {
|
||||
//#given
|
||||
const circular: Record<string, unknown> = { message: "prompt is too long" }
|
||||
circular.self = circular
|
||||
|
||||
//#when
|
||||
const result = parseAnthropicTokenLimitError(circular)
|
||||
|
||||
//#then
|
||||
expect(result).not.toBeNull()
|
||||
})
|
||||
|
||||
it("#given an error where data.responseBody has invalid JSON #when parsing #then handles gracefully", () => {
|
||||
//#given
|
||||
const error = {
|
||||
data: { responseBody: "not valid json {{{" },
|
||||
message: "prompt is too long with 300000 tokens exceeds 200000",
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = parseAnthropicTokenLimitError(error)
|
||||
|
||||
//#then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.currentTokens).toBe(300000)
|
||||
expect(result!.maxTokens).toBe(200000)
|
||||
})
|
||||
|
||||
it("#given an error with data as a string (not object) #when parsing #then does not crash", () => {
|
||||
//#given
|
||||
const error = {
|
||||
data: "some-string-data",
|
||||
message: "token limit exceeded",
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = parseAnthropicTokenLimitError(error)
|
||||
|
||||
//#then
|
||||
expect(result).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -74,6 +74,14 @@ function isTokenLimitError(text: string): boolean {
|
||||
}
|
||||
|
||||
export function parseAnthropicTokenLimitError(err: unknown): ParsedTokenLimitError | null {
|
||||
try {
|
||||
return parseAnthropicTokenLimitErrorUnsafe(err)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function parseAnthropicTokenLimitErrorUnsafe(err: unknown): ParsedTokenLimitError | null {
|
||||
if (typeof err === "string") {
|
||||
if (err.toLowerCase().includes("non-empty content")) {
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
const executeCompactMock = mock(async () => {})
|
||||
const getLastAssistantMock = mock(async () => ({
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-5",
|
||||
}))
|
||||
const parseAnthropicTokenLimitErrorMock = mock(() => ({
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-5",
|
||||
}))
|
||||
|
||||
mock.module("./executor", () => ({
|
||||
executeCompact: executeCompactMock,
|
||||
getLastAssistant: getLastAssistantMock,
|
||||
}))
|
||||
|
||||
mock.module("./parser", () => ({
|
||||
parseAnthropicTokenLimitError: parseAnthropicTokenLimitErrorMock,
|
||||
}))
|
||||
|
||||
mock.module("../../shared/logger", () => ({
|
||||
log: () => {},
|
||||
}))
|
||||
|
||||
function createMockContext(): PluginInput {
|
||||
return {
|
||||
client: {
|
||||
session: {
|
||||
messages: mock(() => Promise.resolve({ data: [] })),
|
||||
},
|
||||
tui: {
|
||||
showToast: mock(() => Promise.resolve()),
|
||||
},
|
||||
},
|
||||
directory: "/tmp",
|
||||
} as PluginInput
|
||||
}
|
||||
|
||||
function setupDelayedTimeoutMocks(): {
|
||||
restore: () => void
|
||||
getClearTimeoutCalls: () => Array<ReturnType<typeof setTimeout>>
|
||||
} {
|
||||
const originalSetTimeout = globalThis.setTimeout
|
||||
const originalClearTimeout = globalThis.clearTimeout
|
||||
const clearTimeoutCalls: Array<ReturnType<typeof setTimeout>> = []
|
||||
let timeoutCounter = 0
|
||||
|
||||
globalThis.setTimeout = ((_: () => void, _delay?: number) => {
|
||||
timeoutCounter += 1
|
||||
return timeoutCounter as ReturnType<typeof setTimeout>
|
||||
}) as typeof setTimeout
|
||||
|
||||
globalThis.clearTimeout = ((timeoutID: ReturnType<typeof setTimeout>) => {
|
||||
clearTimeoutCalls.push(timeoutID)
|
||||
}) as typeof clearTimeout
|
||||
|
||||
return {
|
||||
restore: () => {
|
||||
globalThis.setTimeout = originalSetTimeout
|
||||
globalThis.clearTimeout = originalClearTimeout
|
||||
},
|
||||
getClearTimeoutCalls: () => clearTimeoutCalls,
|
||||
}
|
||||
}
|
||||
|
||||
describe("createAnthropicContextWindowLimitRecoveryHook", () => {
|
||||
beforeEach(() => {
|
||||
executeCompactMock.mockClear()
|
||||
getLastAssistantMock.mockClear()
|
||||
parseAnthropicTokenLimitErrorMock.mockClear()
|
||||
})
|
||||
|
||||
test("cancels pending timer when session.idle handles compaction first", async () => {
|
||||
//#given
|
||||
const { restore, getClearTimeoutCalls } = setupDelayedTimeoutMocks()
|
||||
const { createAnthropicContextWindowLimitRecoveryHook } = await import("./recovery-hook")
|
||||
const hook = createAnthropicContextWindowLimitRecoveryHook(createMockContext())
|
||||
|
||||
try {
|
||||
//#when
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID: "session-race", error: "prompt is too long" },
|
||||
},
|
||||
})
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-race" },
|
||||
},
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(getClearTimeoutCalls()).toEqual([1 as ReturnType<typeof setTimeout>])
|
||||
expect(executeCompactMock).toHaveBeenCalledTimes(1)
|
||||
expect(executeCompactMock.mock.calls[0]?.[0]).toBe("session-race")
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -28,6 +28,7 @@ export function createAnthropicContextWindowLimitRecoveryHook(
|
||||
) {
|
||||
const autoCompactState = createRecoveryState()
|
||||
const experimental = options?.experimental
|
||||
const pendingCompactionTimeoutBySession = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
@@ -35,6 +36,12 @@ export function createAnthropicContextWindowLimitRecoveryHook(
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
const timeoutID = pendingCompactionTimeoutBySession.get(sessionInfo.id)
|
||||
if (timeoutID !== undefined) {
|
||||
clearTimeout(timeoutID)
|
||||
pendingCompactionTimeoutBySession.delete(sessionInfo.id)
|
||||
}
|
||||
|
||||
autoCompactState.pendingCompact.delete(sessionInfo.id)
|
||||
autoCompactState.errorDataBySession.delete(sessionInfo.id)
|
||||
autoCompactState.retryStateBySession.delete(sessionInfo.id)
|
||||
@@ -76,7 +83,8 @@ export function createAnthropicContextWindowLimitRecoveryHook(
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
setTimeout(() => {
|
||||
const timeoutID = setTimeout(() => {
|
||||
pendingCompactionTimeoutBySession.delete(sessionID)
|
||||
executeCompact(
|
||||
sessionID,
|
||||
{ providerID, modelID },
|
||||
@@ -86,6 +94,8 @@ export function createAnthropicContextWindowLimitRecoveryHook(
|
||||
experimental,
|
||||
)
|
||||
}, 300)
|
||||
|
||||
pendingCompactionTimeoutBySession.set(sessionID, timeoutID)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -114,6 +124,12 @@ export function createAnthropicContextWindowLimitRecoveryHook(
|
||||
|
||||
if (!autoCompactState.pendingCompact.has(sessionID)) return
|
||||
|
||||
const timeoutID = pendingCompactionTimeoutBySession.get(sessionID)
|
||||
if (timeoutID !== undefined) {
|
||||
clearTimeout(timeoutID)
|
||||
pendingCompactionTimeoutBySession.delete(sessionID)
|
||||
}
|
||||
|
||||
const errorData = autoCompactState.errorDataBySession.get(sessionID)
|
||||
const lastAssistant = await getLastAssistant(sessionID, ctx.client, ctx.directory)
|
||||
|
||||
|
||||
@@ -10,18 +10,9 @@ import {
|
||||
} from "../../features/boulder-state"
|
||||
import type { BoulderState } from "../../features/boulder-state"
|
||||
|
||||
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-message-storage-${randomUUID()}`)
|
||||
const TEST_MESSAGE_STORAGE = join(TEST_STORAGE_ROOT, "message")
|
||||
const TEST_PART_STORAGE = join(TEST_STORAGE_ROOT, "part")
|
||||
|
||||
mock.module("../../features/hook-message-injector/constants", () => ({
|
||||
OPENCODE_STORAGE: TEST_STORAGE_ROOT,
|
||||
MESSAGE_STORAGE: TEST_MESSAGE_STORAGE,
|
||||
PART_STORAGE: TEST_PART_STORAGE,
|
||||
}))
|
||||
|
||||
const { createAtlasHook } = await import("./index")
|
||||
const { MESSAGE_STORAGE } = await import("../../features/hook-message-injector")
|
||||
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
import { _resetForTesting, subagentSessions } from "../../features/claude-code-session-state"
|
||||
import { createAtlasHook } from "./index"
|
||||
|
||||
describe("atlas hook", () => {
|
||||
let TEST_DIR: string
|
||||
@@ -77,7 +68,6 @@ describe("atlas hook", () => {
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
}
|
||||
rmSync(TEST_STORAGE_ROOT, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("tool.execute.after handler", () => {
|
||||
@@ -631,15 +621,14 @@ describe("atlas hook", () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mock.module("../../features/claude-code-session-state", () => ({
|
||||
getMainSessionID: () => MAIN_SESSION_ID,
|
||||
subagentSessions: new Set<string>(),
|
||||
}))
|
||||
_resetForTesting()
|
||||
subagentSessions.clear()
|
||||
setupMessageStorage(MAIN_SESSION_ID, "atlas")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanupMessageStorage(MAIN_SESSION_ID)
|
||||
_resetForTesting()
|
||||
})
|
||||
|
||||
test("should inject continuation when boulder has incomplete tasks", async () => {
|
||||
|
||||
@@ -1,53 +1,67 @@
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
|
||||
const readFileSyncMock = mock((_: string, __: string) => "# AGENTS")
|
||||
const findAgentsMdUpMock = mock((_: { startDir: string; rootDir: string }) => [] as string[])
|
||||
const resolveFilePathMock = mock((_: string, path: string) => path)
|
||||
const loadInjectedPathsMock = mock((_: string) => new Set<string>())
|
||||
const saveInjectedPathsMock = mock((_: string, __: Set<string>) => {})
|
||||
|
||||
mock.module("node:fs", () => ({
|
||||
readFileSync: readFileSyncMock,
|
||||
}))
|
||||
|
||||
mock.module("./finder", () => ({
|
||||
findAgentsMdUp: findAgentsMdUpMock,
|
||||
resolveFilePath: resolveFilePathMock,
|
||||
}))
|
||||
|
||||
mock.module("./storage", () => ({
|
||||
loadInjectedPaths: loadInjectedPathsMock,
|
||||
saveInjectedPaths: saveInjectedPathsMock,
|
||||
}))
|
||||
|
||||
const { processFilePathForAgentsInjection } = await import("./injector")
|
||||
|
||||
describe("processFilePathForAgentsInjection", () => {
|
||||
let testRoot = ""
|
||||
|
||||
beforeEach(() => {
|
||||
readFileSyncMock.mockClear()
|
||||
findAgentsMdUpMock.mockClear()
|
||||
resolveFilePathMock.mockClear()
|
||||
loadInjectedPathsMock.mockClear()
|
||||
saveInjectedPathsMock.mockClear()
|
||||
|
||||
testRoot = join(
|
||||
tmpdir(),
|
||||
`directory-agents-injector-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
)
|
||||
mkdirSync(testRoot, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
rmSync(testRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("does not save when all discovered paths are already cached", async () => {
|
||||
//#given
|
||||
const sessionID = "session-1"
|
||||
const cachedDirectory = "/repo/src"
|
||||
const repoRoot = join(testRoot, "repo")
|
||||
const agentsPath = join(repoRoot, "src", "AGENTS.md")
|
||||
const cachedDirectory = join(repoRoot, "src")
|
||||
mkdirSync(join(repoRoot, "src"), { recursive: true })
|
||||
writeFileSync(agentsPath, "# AGENTS")
|
||||
|
||||
loadInjectedPathsMock.mockReturnValueOnce(new Set([cachedDirectory]))
|
||||
findAgentsMdUpMock.mockReturnValueOnce(["/repo/src/AGENTS.md"])
|
||||
findAgentsMdUpMock.mockReturnValueOnce([agentsPath])
|
||||
|
||||
const truncator = {
|
||||
truncate: mock(async () => ({ result: "trimmed", truncated: false })),
|
||||
}
|
||||
|
||||
mock.module("./finder", () => ({
|
||||
findAgentsMdUp: findAgentsMdUpMock,
|
||||
resolveFilePath: resolveFilePathMock,
|
||||
}))
|
||||
mock.module("./storage", () => ({
|
||||
loadInjectedPaths: loadInjectedPathsMock,
|
||||
saveInjectedPaths: saveInjectedPathsMock,
|
||||
}))
|
||||
|
||||
const { processFilePathForAgentsInjection } = await import("./injector")
|
||||
|
||||
//#when
|
||||
await processFilePathForAgentsInjection({
|
||||
ctx: { directory: "/repo" } as never,
|
||||
ctx: { directory: repoRoot } as never,
|
||||
truncator: truncator as never,
|
||||
sessionCaches: new Map(),
|
||||
filePath: "/repo/src/file.ts",
|
||||
filePath: join(repoRoot, "src", "file.ts"),
|
||||
sessionID,
|
||||
output: { title: "Result", output: "", metadata: {} },
|
||||
})
|
||||
@@ -59,19 +73,36 @@ describe("processFilePathForAgentsInjection", () => {
|
||||
it("saves when a new path is injected", async () => {
|
||||
//#given
|
||||
const sessionID = "session-2"
|
||||
const repoRoot = join(testRoot, "repo")
|
||||
const agentsPath = join(repoRoot, "src", "AGENTS.md")
|
||||
const injectedDirectory = join(repoRoot, "src")
|
||||
mkdirSync(join(repoRoot, "src"), { recursive: true })
|
||||
writeFileSync(agentsPath, "# AGENTS")
|
||||
|
||||
loadInjectedPathsMock.mockReturnValueOnce(new Set())
|
||||
findAgentsMdUpMock.mockReturnValueOnce(["/repo/src/AGENTS.md"])
|
||||
findAgentsMdUpMock.mockReturnValueOnce([agentsPath])
|
||||
|
||||
const truncator = {
|
||||
truncate: mock(async () => ({ result: "trimmed", truncated: false })),
|
||||
}
|
||||
|
||||
mock.module("./finder", () => ({
|
||||
findAgentsMdUp: findAgentsMdUpMock,
|
||||
resolveFilePath: resolveFilePathMock,
|
||||
}))
|
||||
mock.module("./storage", () => ({
|
||||
loadInjectedPaths: loadInjectedPathsMock,
|
||||
saveInjectedPaths: saveInjectedPathsMock,
|
||||
}))
|
||||
|
||||
const { processFilePathForAgentsInjection } = await import("./injector")
|
||||
|
||||
//#when
|
||||
await processFilePathForAgentsInjection({
|
||||
ctx: { directory: "/repo" } as never,
|
||||
ctx: { directory: repoRoot } as never,
|
||||
truncator: truncator as never,
|
||||
sessionCaches: new Map(),
|
||||
filePath: "/repo/src/file.ts",
|
||||
filePath: join(repoRoot, "src", "file.ts"),
|
||||
sessionID,
|
||||
output: { title: "Result", output: "", metadata: {} },
|
||||
})
|
||||
@@ -80,28 +111,44 @@ describe("processFilePathForAgentsInjection", () => {
|
||||
expect(saveInjectedPathsMock).toHaveBeenCalledTimes(1)
|
||||
const saveCall = saveInjectedPathsMock.mock.calls[0]
|
||||
expect(saveCall[0]).toBe(sessionID)
|
||||
expect((saveCall[1] as Set<string>).has("/repo/src")).toBe(true)
|
||||
expect((saveCall[1] as Set<string>).has(injectedDirectory)).toBe(true)
|
||||
})
|
||||
|
||||
it("saves once when cached and new paths are mixed", async () => {
|
||||
//#given
|
||||
const sessionID = "session-3"
|
||||
loadInjectedPathsMock.mockReturnValueOnce(new Set(["/repo/already-cached"]))
|
||||
findAgentsMdUpMock.mockReturnValueOnce([
|
||||
"/repo/already-cached/AGENTS.md",
|
||||
"/repo/new-dir/AGENTS.md",
|
||||
])
|
||||
const repoRoot = join(testRoot, "repo")
|
||||
const cachedAgentsPath = join(repoRoot, "already-cached", "AGENTS.md")
|
||||
const newAgentsPath = join(repoRoot, "new-dir", "AGENTS.md")
|
||||
mkdirSync(join(repoRoot, "already-cached"), { recursive: true })
|
||||
mkdirSync(join(repoRoot, "new-dir"), { recursive: true })
|
||||
writeFileSync(cachedAgentsPath, "# AGENTS")
|
||||
writeFileSync(newAgentsPath, "# AGENTS")
|
||||
|
||||
loadInjectedPathsMock.mockReturnValueOnce(new Set([join(repoRoot, "already-cached")]))
|
||||
findAgentsMdUpMock.mockReturnValueOnce([cachedAgentsPath, newAgentsPath])
|
||||
|
||||
const truncator = {
|
||||
truncate: mock(async () => ({ result: "trimmed", truncated: false })),
|
||||
}
|
||||
|
||||
mock.module("./finder", () => ({
|
||||
findAgentsMdUp: findAgentsMdUpMock,
|
||||
resolveFilePath: resolveFilePathMock,
|
||||
}))
|
||||
mock.module("./storage", () => ({
|
||||
loadInjectedPaths: loadInjectedPathsMock,
|
||||
saveInjectedPaths: saveInjectedPathsMock,
|
||||
}))
|
||||
|
||||
const { processFilePathForAgentsInjection } = await import("./injector")
|
||||
|
||||
//#when
|
||||
await processFilePathForAgentsInjection({
|
||||
ctx: { directory: "/repo" } as never,
|
||||
ctx: { directory: repoRoot } as never,
|
||||
truncator: truncator as never,
|
||||
sessionCaches: new Map(),
|
||||
filePath: "/repo/new-dir/file.ts",
|
||||
filePath: join(repoRoot, "new-dir", "file.ts"),
|
||||
sessionID,
|
||||
output: { title: "Result", output: "", metadata: {} },
|
||||
})
|
||||
@@ -109,6 +156,6 @@ describe("processFilePathForAgentsInjection", () => {
|
||||
//#then
|
||||
expect(saveInjectedPathsMock).toHaveBeenCalledTimes(1)
|
||||
const saveCall = saveInjectedPathsMock.mock.calls[0]
|
||||
expect((saveCall[1] as Set<string>).has("/repo/new-dir")).toBe(true)
|
||||
expect((saveCall[1] as Set<string>).has(join(repoRoot, "new-dir"))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,53 +1,67 @@
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
|
||||
const readFileSyncMock = mock((_: string, __: string) => "# README")
|
||||
const findReadmeMdUpMock = mock((_: { startDir: string; rootDir: string }) => [] as string[])
|
||||
const resolveFilePathMock = mock((_: string, path: string) => path)
|
||||
const loadInjectedPathsMock = mock((_: string) => new Set<string>())
|
||||
const saveInjectedPathsMock = mock((_: string, __: Set<string>) => {})
|
||||
|
||||
mock.module("node:fs", () => ({
|
||||
readFileSync: readFileSyncMock,
|
||||
}))
|
||||
|
||||
mock.module("./finder", () => ({
|
||||
findReadmeMdUp: findReadmeMdUpMock,
|
||||
resolveFilePath: resolveFilePathMock,
|
||||
}))
|
||||
|
||||
mock.module("./storage", () => ({
|
||||
loadInjectedPaths: loadInjectedPathsMock,
|
||||
saveInjectedPaths: saveInjectedPathsMock,
|
||||
}))
|
||||
|
||||
const { processFilePathForReadmeInjection } = await import("./injector")
|
||||
|
||||
describe("processFilePathForReadmeInjection", () => {
|
||||
let testRoot = ""
|
||||
|
||||
beforeEach(() => {
|
||||
readFileSyncMock.mockClear()
|
||||
findReadmeMdUpMock.mockClear()
|
||||
resolveFilePathMock.mockClear()
|
||||
loadInjectedPathsMock.mockClear()
|
||||
saveInjectedPathsMock.mockClear()
|
||||
|
||||
testRoot = join(
|
||||
tmpdir(),
|
||||
`directory-readme-injector-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
)
|
||||
mkdirSync(testRoot, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
rmSync(testRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("does not save when all discovered paths are already cached", async () => {
|
||||
//#given
|
||||
const sessionID = "session-1"
|
||||
const cachedDirectory = "/repo/src"
|
||||
const repoRoot = join(testRoot, "repo")
|
||||
const readmePath = join(repoRoot, "src", "README.md")
|
||||
const cachedDirectory = join(repoRoot, "src")
|
||||
mkdirSync(join(repoRoot, "src"), { recursive: true })
|
||||
writeFileSync(readmePath, "# README")
|
||||
|
||||
loadInjectedPathsMock.mockReturnValueOnce(new Set([cachedDirectory]))
|
||||
findReadmeMdUpMock.mockReturnValueOnce(["/repo/src/README.md"])
|
||||
findReadmeMdUpMock.mockReturnValueOnce([readmePath])
|
||||
|
||||
const truncator = {
|
||||
truncate: mock(async () => ({ result: "trimmed", truncated: false })),
|
||||
}
|
||||
|
||||
mock.module("./finder", () => ({
|
||||
findReadmeMdUp: findReadmeMdUpMock,
|
||||
resolveFilePath: resolveFilePathMock,
|
||||
}))
|
||||
mock.module("./storage", () => ({
|
||||
loadInjectedPaths: loadInjectedPathsMock,
|
||||
saveInjectedPaths: saveInjectedPathsMock,
|
||||
}))
|
||||
|
||||
const { processFilePathForReadmeInjection } = await import("./injector")
|
||||
|
||||
//#when
|
||||
await processFilePathForReadmeInjection({
|
||||
ctx: { directory: "/repo" } as never,
|
||||
ctx: { directory: repoRoot } as never,
|
||||
truncator: truncator as never,
|
||||
sessionCaches: new Map(),
|
||||
filePath: "/repo/src/file.ts",
|
||||
filePath: join(repoRoot, "src", "file.ts"),
|
||||
sessionID,
|
||||
output: { title: "Result", output: "", metadata: {} },
|
||||
})
|
||||
@@ -59,19 +73,36 @@ describe("processFilePathForReadmeInjection", () => {
|
||||
it("saves when a new path is injected", async () => {
|
||||
//#given
|
||||
const sessionID = "session-2"
|
||||
const repoRoot = join(testRoot, "repo")
|
||||
const readmePath = join(repoRoot, "src", "README.md")
|
||||
const injectedDirectory = join(repoRoot, "src")
|
||||
mkdirSync(join(repoRoot, "src"), { recursive: true })
|
||||
writeFileSync(readmePath, "# README")
|
||||
|
||||
loadInjectedPathsMock.mockReturnValueOnce(new Set())
|
||||
findReadmeMdUpMock.mockReturnValueOnce(["/repo/src/README.md"])
|
||||
findReadmeMdUpMock.mockReturnValueOnce([readmePath])
|
||||
|
||||
const truncator = {
|
||||
truncate: mock(async () => ({ result: "trimmed", truncated: false })),
|
||||
}
|
||||
|
||||
mock.module("./finder", () => ({
|
||||
findReadmeMdUp: findReadmeMdUpMock,
|
||||
resolveFilePath: resolveFilePathMock,
|
||||
}))
|
||||
mock.module("./storage", () => ({
|
||||
loadInjectedPaths: loadInjectedPathsMock,
|
||||
saveInjectedPaths: saveInjectedPathsMock,
|
||||
}))
|
||||
|
||||
const { processFilePathForReadmeInjection } = await import("./injector")
|
||||
|
||||
//#when
|
||||
await processFilePathForReadmeInjection({
|
||||
ctx: { directory: "/repo" } as never,
|
||||
ctx: { directory: repoRoot } as never,
|
||||
truncator: truncator as never,
|
||||
sessionCaches: new Map(),
|
||||
filePath: "/repo/src/file.ts",
|
||||
filePath: join(repoRoot, "src", "file.ts"),
|
||||
sessionID,
|
||||
output: { title: "Result", output: "", metadata: {} },
|
||||
})
|
||||
@@ -80,28 +111,44 @@ describe("processFilePathForReadmeInjection", () => {
|
||||
expect(saveInjectedPathsMock).toHaveBeenCalledTimes(1)
|
||||
const saveCall = saveInjectedPathsMock.mock.calls[0]
|
||||
expect(saveCall[0]).toBe(sessionID)
|
||||
expect((saveCall[1] as Set<string>).has("/repo/src")).toBe(true)
|
||||
expect((saveCall[1] as Set<string>).has(injectedDirectory)).toBe(true)
|
||||
})
|
||||
|
||||
it("saves once when cached and new paths are mixed", async () => {
|
||||
//#given
|
||||
const sessionID = "session-3"
|
||||
loadInjectedPathsMock.mockReturnValueOnce(new Set(["/repo/already-cached"]))
|
||||
findReadmeMdUpMock.mockReturnValueOnce([
|
||||
"/repo/already-cached/README.md",
|
||||
"/repo/new-dir/README.md",
|
||||
])
|
||||
const repoRoot = join(testRoot, "repo")
|
||||
const cachedReadmePath = join(repoRoot, "already-cached", "README.md")
|
||||
const newReadmePath = join(repoRoot, "new-dir", "README.md")
|
||||
mkdirSync(join(repoRoot, "already-cached"), { recursive: true })
|
||||
mkdirSync(join(repoRoot, "new-dir"), { recursive: true })
|
||||
writeFileSync(cachedReadmePath, "# README")
|
||||
writeFileSync(newReadmePath, "# README")
|
||||
|
||||
loadInjectedPathsMock.mockReturnValueOnce(new Set([join(repoRoot, "already-cached")]))
|
||||
findReadmeMdUpMock.mockReturnValueOnce([cachedReadmePath, newReadmePath])
|
||||
|
||||
const truncator = {
|
||||
truncate: mock(async () => ({ result: "trimmed", truncated: false })),
|
||||
}
|
||||
|
||||
mock.module("./finder", () => ({
|
||||
findReadmeMdUp: findReadmeMdUpMock,
|
||||
resolveFilePath: resolveFilePathMock,
|
||||
}))
|
||||
mock.module("./storage", () => ({
|
||||
loadInjectedPaths: loadInjectedPathsMock,
|
||||
saveInjectedPaths: saveInjectedPathsMock,
|
||||
}))
|
||||
|
||||
const { processFilePathForReadmeInjection } = await import("./injector")
|
||||
|
||||
//#when
|
||||
await processFilePathForReadmeInjection({
|
||||
ctx: { directory: "/repo" } as never,
|
||||
ctx: { directory: repoRoot } as never,
|
||||
truncator: truncator as never,
|
||||
sessionCaches: new Map(),
|
||||
filePath: "/repo/new-dir/file.ts",
|
||||
filePath: join(repoRoot, "new-dir", "file.ts"),
|
||||
sessionID,
|
||||
output: { title: "Result", output: "", metadata: {} },
|
||||
})
|
||||
@@ -109,6 +156,6 @@ describe("processFilePathForReadmeInjection", () => {
|
||||
//#then
|
||||
expect(saveInjectedPathsMock).toHaveBeenCalledTimes(1)
|
||||
const saveCall = saveInjectedPathsMock.mock.calls[0]
|
||||
expect((saveCall[1] as Set<string>).has("/repo/new-dir")).toBe(true)
|
||||
expect((saveCall[1] as Set<string>).has(join(repoRoot, "new-dir"))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, it, expect, mock, beforeEach } from "bun:test"
|
||||
import { createPreemptiveCompactionHook } from "./preemptive-compaction"
|
||||
|
||||
const logMock = mock(() => {})
|
||||
|
||||
mock.module("../shared/logger", () => ({
|
||||
log: logMock,
|
||||
}))
|
||||
|
||||
const { createPreemptiveCompactionHook } = await import("./preemptive-compaction")
|
||||
|
||||
function createMockCtx() {
|
||||
return {
|
||||
@@ -21,6 +28,7 @@ describe("preemptive-compaction", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = createMockCtx()
|
||||
logMock.mockClear()
|
||||
})
|
||||
|
||||
// #given event caches token info from message.updated
|
||||
@@ -152,4 +160,45 @@ describe("preemptive-compaction", () => {
|
||||
|
||||
expect(ctx.client.session.summarize).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should log summarize errors instead of swallowing them", async () => {
|
||||
//#given
|
||||
const hook = createPreemptiveCompactionHook(ctx as never)
|
||||
const sessionID = "ses_log_error"
|
||||
const summarizeError = new Error("summarize failed")
|
||||
ctx.client.session.summarize.mockRejectedValueOnce(summarizeError)
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-5",
|
||||
finish: true,
|
||||
tokens: {
|
||||
input: 170000,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 10000, write: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](
|
||||
{ tool: "bash", sessionID, callID: "call_log" },
|
||||
{ title: "", output: "test", metadata: null }
|
||||
)
|
||||
|
||||
//#then
|
||||
expect(logMock).toHaveBeenCalledWith("[preemptive-compaction] Compaction failed", {
|
||||
sessionID,
|
||||
error: String(summarizeError),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { log } from "../shared/logger"
|
||||
|
||||
const DEFAULT_ACTUAL_LIMIT = 200_000
|
||||
|
||||
const ANTHROPIC_ACTUAL_LIMIT =
|
||||
@@ -76,8 +78,8 @@ export function createPreemptiveCompactionHook(ctx: PluginInput) {
|
||||
})
|
||||
|
||||
compactedSessions.add(sessionID)
|
||||
} catch {
|
||||
// best-effort; do not disrupt tool execution
|
||||
} catch (error) {
|
||||
log("[preemptive-compaction] Compaction failed", { sessionID, error: String(error) })
|
||||
} finally {
|
||||
compactionInProgress.delete(sessionID)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"
|
||||
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
@@ -6,18 +6,8 @@ import { randomUUID } from "node:crypto"
|
||||
import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive"
|
||||
import { clearSessionAgent } from "../../features/claude-code-session-state"
|
||||
|
||||
const TEST_STORAGE_ROOT = join(tmpdir(), `prometheus-md-only-${randomUUID()}`)
|
||||
const TEST_MESSAGE_STORAGE = join(TEST_STORAGE_ROOT, "message")
|
||||
const TEST_PART_STORAGE = join(TEST_STORAGE_ROOT, "part")
|
||||
|
||||
mock.module("../../features/hook-message-injector/constants", () => ({
|
||||
OPENCODE_STORAGE: TEST_STORAGE_ROOT,
|
||||
MESSAGE_STORAGE: TEST_MESSAGE_STORAGE,
|
||||
PART_STORAGE: TEST_PART_STORAGE,
|
||||
}))
|
||||
|
||||
const { createPrometheusMdOnlyHook } = await import("./index")
|
||||
const { MESSAGE_STORAGE } = await import("../../features/hook-message-injector")
|
||||
import { createPrometheusMdOnlyHook } from "./index"
|
||||
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
|
||||
describe("prometheus-md-only", () => {
|
||||
const TEST_SESSION_ID = "test-session-prometheus"
|
||||
@@ -52,7 +42,6 @@ describe("prometheus-md-only", () => {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
rmSync(TEST_STORAGE_ROOT, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("agent name matching", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import * as os from "node:os";
|
||||
@@ -102,6 +102,10 @@ function getInjectedRulesPath(sessionID: string): string {
|
||||
}
|
||||
|
||||
describe("createRuleInjectionProcessor", () => {
|
||||
afterAll(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
let testRoot: string;
|
||||
let projectRoot: string;
|
||||
let homeRoot: string;
|
||||
|
||||
129
src/hooks/session-recovery/detect-error-type.test.ts
Normal file
129
src/hooks/session-recovery/detect-error-type.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { detectErrorType, extractMessageIndex } from "./detect-error-type"
|
||||
|
||||
describe("detectErrorType", () => {
|
||||
it("#given a tool_use/tool_result error #when detecting #then returns tool_result_missing", () => {
|
||||
//#given
|
||||
const error = { message: "tool_use block must be followed by tool_result" }
|
||||
|
||||
//#when
|
||||
const result = detectErrorType(error)
|
||||
|
||||
//#then
|
||||
expect(result).toBe("tool_result_missing")
|
||||
})
|
||||
|
||||
it("#given a thinking block order error #when detecting #then returns thinking_block_order", () => {
|
||||
//#given
|
||||
const error = { message: "thinking must be the first block in the response" }
|
||||
|
||||
//#when
|
||||
const result = detectErrorType(error)
|
||||
|
||||
//#then
|
||||
expect(result).toBe("thinking_block_order")
|
||||
})
|
||||
|
||||
it("#given a thinking disabled violation #when detecting #then returns thinking_disabled_violation", () => {
|
||||
//#given
|
||||
const error = { message: "thinking is disabled and cannot contain thinking blocks" }
|
||||
|
||||
//#when
|
||||
const result = detectErrorType(error)
|
||||
|
||||
//#then
|
||||
expect(result).toBe("thinking_disabled_violation")
|
||||
})
|
||||
|
||||
it("#given an unrecognized error #when detecting #then returns null", () => {
|
||||
//#given
|
||||
const error = { message: "some random error" }
|
||||
|
||||
//#when
|
||||
const result = detectErrorType(error)
|
||||
|
||||
//#then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("#given a malformed error with circular references #when detecting #then returns null without crashing", () => {
|
||||
//#given
|
||||
const circular: Record<string, unknown> = {}
|
||||
circular.self = circular
|
||||
|
||||
//#when
|
||||
const result = detectErrorType(circular)
|
||||
|
||||
//#then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("#given a proxy error with non-standard structure #when detecting #then returns null without crashing", () => {
|
||||
//#given
|
||||
const proxyError = {
|
||||
data: "not-an-object",
|
||||
error: 42,
|
||||
nested: { deeply: { error: true } },
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = detectErrorType(proxyError)
|
||||
|
||||
//#then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("#given a null error #when detecting #then returns null", () => {
|
||||
//#given
|
||||
const error = null
|
||||
|
||||
//#when
|
||||
const result = detectErrorType(error)
|
||||
|
||||
//#then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("#given an error with data.error containing message #when detecting #then extracts correctly", () => {
|
||||
//#given
|
||||
const error = {
|
||||
data: {
|
||||
error: {
|
||||
message: "tool_use block requires tool_result",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = detectErrorType(error)
|
||||
|
||||
//#then
|
||||
expect(result).toBe("tool_result_missing")
|
||||
})
|
||||
})
|
||||
|
||||
describe("extractMessageIndex", () => {
|
||||
it("#given an error referencing messages.5 #when extracting #then returns 5", () => {
|
||||
//#given
|
||||
const error = { message: "Invalid value at messages.5: tool_result is required" }
|
||||
|
||||
//#when
|
||||
const result = extractMessageIndex(error)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(5)
|
||||
})
|
||||
|
||||
it("#given a malformed error #when extracting #then returns null without crashing", () => {
|
||||
//#given
|
||||
const circular: Record<string, unknown> = {}
|
||||
circular.self = circular
|
||||
|
||||
//#when
|
||||
const result = extractMessageIndex(circular)
|
||||
|
||||
//#then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -34,40 +34,48 @@ function getErrorMessage(error: unknown): string {
|
||||
}
|
||||
|
||||
export function extractMessageIndex(error: unknown): number | null {
|
||||
const message = getErrorMessage(error)
|
||||
const match = message.match(/messages\.(\d+)/)
|
||||
return match ? parseInt(match[1], 10) : null
|
||||
try {
|
||||
const message = getErrorMessage(error)
|
||||
const match = message.match(/messages\.(\d+)/)
|
||||
return match ? parseInt(match[1], 10) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function detectErrorType(error: unknown): RecoveryErrorType {
|
||||
const message = getErrorMessage(error)
|
||||
try {
|
||||
const message = getErrorMessage(error)
|
||||
|
||||
if (
|
||||
message.includes("assistant message prefill") ||
|
||||
message.includes("conversation must end with a user message")
|
||||
) {
|
||||
return "assistant_prefill_unsupported"
|
||||
if (
|
||||
message.includes("assistant message prefill") ||
|
||||
message.includes("conversation must end with a user message")
|
||||
) {
|
||||
return "assistant_prefill_unsupported"
|
||||
}
|
||||
|
||||
if (
|
||||
message.includes("thinking") &&
|
||||
(message.includes("first block") ||
|
||||
message.includes("must start with") ||
|
||||
message.includes("preceeding") ||
|
||||
message.includes("final block") ||
|
||||
message.includes("cannot be thinking") ||
|
||||
(message.includes("expected") && message.includes("found")))
|
||||
) {
|
||||
return "thinking_block_order"
|
||||
}
|
||||
|
||||
if (message.includes("thinking is disabled") && message.includes("cannot contain")) {
|
||||
return "thinking_disabled_violation"
|
||||
}
|
||||
|
||||
if (message.includes("tool_use") && message.includes("tool_result")) {
|
||||
return "tool_result_missing"
|
||||
}
|
||||
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (
|
||||
message.includes("thinking") &&
|
||||
(message.includes("first block") ||
|
||||
message.includes("must start with") ||
|
||||
message.includes("preceeding") ||
|
||||
message.includes("final block") ||
|
||||
message.includes("cannot be thinking") ||
|
||||
(message.includes("expected") && message.includes("found")))
|
||||
) {
|
||||
return "thinking_block_order"
|
||||
}
|
||||
|
||||
if (message.includes("thinking is disabled") && message.includes("cannot contain")) {
|
||||
return "thinking_disabled_violation"
|
||||
}
|
||||
|
||||
if (message.includes("tool_use") && message.includes("tool_result")) {
|
||||
return "tool_result_missing"
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -10,6 +10,45 @@ export function clearThinkModeState(sessionID: string): void {
|
||||
}
|
||||
|
||||
export function createThinkModeHook() {
|
||||
function isDisabledThinkingConfig(config: Record<string, unknown>): boolean {
|
||||
const thinkingConfig = config.thinking
|
||||
if (
|
||||
typeof thinkingConfig === "object" &&
|
||||
thinkingConfig !== null &&
|
||||
"type" in thinkingConfig &&
|
||||
(thinkingConfig as { type?: string }).type === "disabled"
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
const providerOptions = config.providerOptions
|
||||
if (typeof providerOptions !== "object" || providerOptions === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
return Object.values(providerOptions as Record<string, unknown>).some(
|
||||
(providerConfig) => {
|
||||
if (typeof providerConfig !== "object" || providerConfig === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
const providerConfigMap = providerConfig as Record<string, unknown>
|
||||
const extraBody = providerConfigMap.extra_body
|
||||
if (typeof extraBody !== "object" || extraBody === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
const extraBodyMap = extraBody as Record<string, unknown>
|
||||
const extraThinking = extraBodyMap.thinking
|
||||
return (
|
||||
typeof extraThinking === "object" &&
|
||||
extraThinking !== null &&
|
||||
(extraThinking as { type?: string }).type === "disabled"
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
"chat.params": async (output: ThinkModeInput, sessionID: string): Promise<void> => {
|
||||
const promptText = extractPromptText(output.parts)
|
||||
@@ -75,7 +114,9 @@ export function createThinkModeHook() {
|
||||
sessionID,
|
||||
provider: currentModel.providerID,
|
||||
})
|
||||
} else {
|
||||
} else if (
|
||||
!isDisabledThinkingConfig(thinkingConfig as Record<string, unknown>)
|
||||
) {
|
||||
Object.assign(output.message, thinkingConfig)
|
||||
state.thinkingConfigInjected = true
|
||||
log("Think mode: thinking config injected", {
|
||||
@@ -83,6 +124,11 @@ export function createThinkModeHook() {
|
||||
provider: currentModel.providerID,
|
||||
config: thinkingConfig,
|
||||
})
|
||||
} else {
|
||||
log("Think mode: skipping disabled thinking config", {
|
||||
sessionID,
|
||||
provider: currentModel.providerID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -352,6 +352,25 @@ describe("createThinkModeHook integration", () => {
|
||||
})
|
||||
|
||||
describe("Agent-level thinking configuration respect", () => {
|
||||
it("should omit Z.ai GLM disabled thinking config", async () => {
|
||||
//#given a Z.ai GLM model with think prompt
|
||||
const hook = createThinkModeHook()
|
||||
const input = createMockInput(
|
||||
"zai-coding-plan",
|
||||
"glm-4.7",
|
||||
"ultrathink mode"
|
||||
)
|
||||
|
||||
//#when think mode resolves Z.ai thinking configuration
|
||||
await hook["chat.params"](input, sessionID)
|
||||
|
||||
//#then thinking config should be omitted from request
|
||||
const message = input.message as MessageWithInjectedProps
|
||||
expect(input.message.model?.modelID).toBe("glm-4.7")
|
||||
expect(message.thinking).toBeUndefined()
|
||||
expect(message.providerOptions).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should NOT inject thinking config when agent has thinking disabled", async () => {
|
||||
// given agent with thinking explicitly disabled
|
||||
const hook = createThinkModeHook()
|
||||
|
||||
@@ -470,10 +470,12 @@ describe("think-mode switcher", () => {
|
||||
describe("Z.AI GLM-4.7 provider support", () => {
|
||||
describe("getThinkingConfig for zai-coding-plan", () => {
|
||||
it("should return thinking config for glm-4.7", () => {
|
||||
// given zai-coding-plan provider with glm-4.7 model
|
||||
//#given a Z.ai GLM model
|
||||
const config = getThinkingConfig("zai-coding-plan", "glm-4.7")
|
||||
|
||||
// then should return zai-coding-plan thinking config
|
||||
//#when thinking config is resolved
|
||||
|
||||
//#then thinking type is "disabled"
|
||||
expect(config).not.toBeNull()
|
||||
expect(config?.providerOptions).toBeDefined()
|
||||
const zaiOptions = (config?.providerOptions as Record<string, unknown>)?.[
|
||||
@@ -482,8 +484,7 @@ describe("think-mode switcher", () => {
|
||||
expect(zaiOptions?.extra_body).toBeDefined()
|
||||
const extraBody = zaiOptions?.extra_body as Record<string, unknown>
|
||||
expect(extraBody?.thinking).toBeDefined()
|
||||
expect((extraBody?.thinking as Record<string, unknown>)?.type).toBe("enabled")
|
||||
expect((extraBody?.thinking as Record<string, unknown>)?.clear_thinking).toBe(false)
|
||||
expect((extraBody?.thinking as Record<string, unknown>)?.type).toBe("disabled")
|
||||
})
|
||||
|
||||
it("should return thinking config for glm-4.6v (multimodal)", () => {
|
||||
@@ -505,7 +506,7 @@ describe("think-mode switcher", () => {
|
||||
})
|
||||
|
||||
describe("HIGH_VARIANT_MAP for GLM", () => {
|
||||
it("should NOT have high variant for glm-4.7 (thinking enabled by default)", () => {
|
||||
it("should NOT have high variant for glm-4.7", () => {
|
||||
// given glm-4.7 model
|
||||
const variant = getHighVariant("glm-4.7")
|
||||
|
||||
|
||||
@@ -154,8 +154,7 @@ export const THINKING_CONFIGS = {
|
||||
"zai-coding-plan": {
|
||||
extra_body: {
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
clear_thinking: false,
|
||||
type: "disabled",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -18,3 +18,5 @@ export const COUNTDOWN_GRACE_PERIOD_MS = 500
|
||||
|
||||
export const ABORT_WINDOW_MS = 3000
|
||||
export const CONTINUATION_COOLDOWN_MS = 30_000
|
||||
export const MAX_CONSECUTIVE_FAILURES = 5
|
||||
export const FAILURE_RESET_WINDOW_MS = 5 * 60 * 1000
|
||||
|
||||
@@ -141,11 +141,14 @@ ${todoList}`
|
||||
if (injectionState) {
|
||||
injectionState.inFlight = false
|
||||
injectionState.lastInjectedAt = Date.now()
|
||||
injectionState.consecutiveFailures = 0
|
||||
}
|
||||
} catch (error) {
|
||||
log(`[${HOOK_NAME}] Injection failed`, { sessionID, error: String(error) })
|
||||
if (injectionState) {
|
||||
injectionState.inFlight = false
|
||||
injectionState.lastInjectedAt = Date.now()
|
||||
injectionState.consecutiveFailures = (injectionState.consecutiveFailures ?? 0) + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
ABORT_WINDOW_MS,
|
||||
CONTINUATION_COOLDOWN_MS,
|
||||
DEFAULT_SKIP_AGENTS,
|
||||
FAILURE_RESET_WINDOW_MS,
|
||||
HOOK_NAME,
|
||||
MAX_CONSECUTIVE_FAILURES,
|
||||
} from "./constants"
|
||||
import { isLastAssistantMessageAborted } from "./abort-detection"
|
||||
import { getIncompleteCount } from "./todo"
|
||||
@@ -99,8 +101,35 @@ export async function handleSessionIdle(args: {
|
||||
return
|
||||
}
|
||||
|
||||
if (state.lastInjectedAt && Date.now() - state.lastInjectedAt < CONTINUATION_COOLDOWN_MS) {
|
||||
log(`[${HOOK_NAME}] Skipped: cooldown active`, { sessionID })
|
||||
if (
|
||||
state.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES
|
||||
&& state.lastInjectedAt
|
||||
&& Date.now() - state.lastInjectedAt >= FAILURE_RESET_WINDOW_MS
|
||||
) {
|
||||
state.consecutiveFailures = 0
|
||||
log(`[${HOOK_NAME}] Reset consecutive failures after recovery window`, {
|
||||
sessionID,
|
||||
failureResetWindowMs: FAILURE_RESET_WINDOW_MS,
|
||||
})
|
||||
}
|
||||
|
||||
if (state.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
||||
log(`[${HOOK_NAME}] Skipped: max consecutive failures reached`, {
|
||||
sessionID,
|
||||
consecutiveFailures: state.consecutiveFailures,
|
||||
maxConsecutiveFailures: MAX_CONSECUTIVE_FAILURES,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const effectiveCooldown =
|
||||
CONTINUATION_COOLDOWN_MS * Math.pow(2, Math.min(state.consecutiveFailures, 5))
|
||||
if (state.lastInjectedAt && Date.now() - state.lastInjectedAt < effectiveCooldown) {
|
||||
log(`[${HOOK_NAME}] Skipped: cooldown active`, {
|
||||
sessionID,
|
||||
effectiveCooldown,
|
||||
consecutiveFailures: state.consecutiveFailures,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,9 @@ export function createSessionStateStore(): SessionStateStore {
|
||||
return existing.state
|
||||
}
|
||||
|
||||
const state: SessionState = {}
|
||||
const state: SessionState = {
|
||||
consecutiveFailures: 0,
|
||||
}
|
||||
sessions.set(sessionID, { state, lastAccessedAt: Date.now() })
|
||||
return state
|
||||
}
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import { setMainSession, subagentSessions, _resetForTesting } from "../../features/claude-code-session-state"
|
||||
import { createTodoContinuationEnforcer } from "."
|
||||
import { CONTINUATION_COOLDOWN_MS } from "./constants"
|
||||
import {
|
||||
CONTINUATION_COOLDOWN_MS,
|
||||
FAILURE_RESET_WINDOW_MS,
|
||||
MAX_CONSECUTIVE_FAILURES,
|
||||
} from "./constants"
|
||||
|
||||
type TimerCallback = (...args: any[]) => void
|
||||
|
||||
interface FakeTimers {
|
||||
advanceBy: (ms: number, advanceClock?: boolean) => Promise<void>
|
||||
advanceClockBy: (ms: number) => Promise<void>
|
||||
restore: () => void
|
||||
}
|
||||
|
||||
function createFakeTimers(): FakeTimers {
|
||||
const FAKE_MIN_DELAY_MS = 500
|
||||
const REAL_MAX_DELAY_MS = 5000
|
||||
const originalNow = Date.now()
|
||||
let clockNow = originalNow
|
||||
let timerNow = 0
|
||||
@@ -52,20 +60,41 @@ function createFakeTimers(): FakeTimers {
|
||||
}
|
||||
|
||||
globalThis.setTimeout = ((callback: TimerCallback, delay?: number, ...args: any[]) => {
|
||||
return schedule(callback, delay, null, args) as unknown as ReturnType<typeof setTimeout>
|
||||
const normalized = normalizeDelay(delay)
|
||||
if (normalized < FAKE_MIN_DELAY_MS) {
|
||||
return original.setTimeout(callback, delay, ...args)
|
||||
}
|
||||
if (normalized >= REAL_MAX_DELAY_MS) {
|
||||
return original.setTimeout(callback, delay, ...args)
|
||||
}
|
||||
return schedule(callback, normalized, null, args) as unknown as ReturnType<typeof setTimeout>
|
||||
}) as typeof setTimeout
|
||||
|
||||
globalThis.setInterval = ((callback: TimerCallback, delay?: number, ...args: any[]) => {
|
||||
const interval = normalizeDelay(delay)
|
||||
return schedule(callback, delay, interval, args) as unknown as ReturnType<typeof setInterval>
|
||||
if (interval < FAKE_MIN_DELAY_MS) {
|
||||
return original.setInterval(callback, delay, ...args)
|
||||
}
|
||||
if (interval >= REAL_MAX_DELAY_MS) {
|
||||
return original.setInterval(callback, delay, ...args)
|
||||
}
|
||||
return schedule(callback, interval, interval, args) as unknown as ReturnType<typeof setInterval>
|
||||
}) as typeof setInterval
|
||||
|
||||
globalThis.clearTimeout = ((id?: number) => {
|
||||
clear(id)
|
||||
globalThis.clearTimeout = ((id?: Parameters<typeof clearTimeout>[0]) => {
|
||||
if (typeof id === "number" && timers.has(id)) {
|
||||
clear(id)
|
||||
return
|
||||
}
|
||||
original.clearTimeout(id)
|
||||
}) as typeof clearTimeout
|
||||
|
||||
globalThis.clearInterval = ((id?: number) => {
|
||||
clear(id)
|
||||
globalThis.clearInterval = ((id?: Parameters<typeof clearInterval>[0]) => {
|
||||
if (typeof id === "number" && timers.has(id)) {
|
||||
clear(id)
|
||||
return
|
||||
}
|
||||
original.clearInterval(id)
|
||||
}) as typeof clearInterval
|
||||
|
||||
Date.now = () => clockNow
|
||||
@@ -107,6 +136,12 @@ function createFakeTimers(): FakeTimers {
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
const advanceClockBy = async (ms: number) => {
|
||||
const clamped = Math.max(0, ms)
|
||||
clockNow += clamped
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
const restore = () => {
|
||||
globalThis.setTimeout = original.setTimeout
|
||||
globalThis.clearTimeout = original.clearTimeout
|
||||
@@ -115,7 +150,7 @@ function createFakeTimers(): FakeTimers {
|
||||
Date.now = original.dateNow
|
||||
}
|
||||
|
||||
return { advanceBy, restore }
|
||||
return { advanceBy, advanceClockBy, restore }
|
||||
}
|
||||
|
||||
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
@@ -133,6 +168,15 @@ describe("todo-continuation-enforcer", () => {
|
||||
}
|
||||
}
|
||||
|
||||
interface PromptRequestOptions {
|
||||
path: { id: string }
|
||||
body: {
|
||||
agent?: string
|
||||
model?: { providerID?: string; modelID?: string }
|
||||
parts: Array<{ text: string }>
|
||||
}
|
||||
}
|
||||
|
||||
let mockMessages: MockMessage[] = []
|
||||
|
||||
function createMockPluginInput() {
|
||||
@@ -510,7 +554,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
await fakeTimers.advanceBy(2500, true)
|
||||
await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true)
|
||||
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
@@ -518,8 +562,166 @@ describe("todo-continuation-enforcer", () => {
|
||||
|
||||
//#then
|
||||
expect(promptCalls).toHaveLength(2)
|
||||
}, { timeout: 15000 })
|
||||
|
||||
test("should apply cooldown even after injection failure", async () => {
|
||||
//#given
|
||||
const sessionID = "main-failure-cooldown"
|
||||
setMainSession(sessionID)
|
||||
const mockInput = createMockPluginInput()
|
||||
mockInput.client.session.promptAsync = async (opts: PromptRequestOptions) => {
|
||||
promptCalls.push({
|
||||
sessionID: opts.path.id,
|
||||
agent: opts.body.agent,
|
||||
model: opts.body.model,
|
||||
text: opts.body.parts[0].text,
|
||||
})
|
||||
throw new Error("simulated auth failure")
|
||||
}
|
||||
const hook = createTodoContinuationEnforcer(mockInput, {})
|
||||
|
||||
//#when
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||
await fakeTimers.advanceBy(2500, true)
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||
await fakeTimers.advanceBy(2500, true)
|
||||
|
||||
//#then
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("should stop retries after max consecutive failures", async () => {
|
||||
//#given
|
||||
const sessionID = "main-max-consecutive-failures"
|
||||
setMainSession(sessionID)
|
||||
const mockInput = createMockPluginInput()
|
||||
mockInput.client.session.promptAsync = async (opts: PromptRequestOptions) => {
|
||||
promptCalls.push({
|
||||
sessionID: opts.path.id,
|
||||
agent: opts.body.agent,
|
||||
model: opts.body.model,
|
||||
text: opts.body.parts[0].text,
|
||||
})
|
||||
throw new Error("simulated auth failure")
|
||||
}
|
||||
const hook = createTodoContinuationEnforcer(mockInput, {})
|
||||
|
||||
//#when
|
||||
for (let index = 0; index < MAX_CONSECUTIVE_FAILURES; index++) {
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||
await fakeTimers.advanceBy(2500, true)
|
||||
if (index < MAX_CONSECUTIVE_FAILURES - 1) {
|
||||
await fakeTimers.advanceClockBy(1_000_000)
|
||||
}
|
||||
}
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||
await fakeTimers.advanceBy(2500, true)
|
||||
|
||||
//#then
|
||||
expect(promptCalls).toHaveLength(MAX_CONSECUTIVE_FAILURES)
|
||||
}, { timeout: 30000 })
|
||||
|
||||
test("should resume retries after reset window when max failures reached", async () => {
|
||||
//#given
|
||||
const sessionID = "main-recovery-after-max-failures"
|
||||
setMainSession(sessionID)
|
||||
const mockInput = createMockPluginInput()
|
||||
mockInput.client.session.promptAsync = async (opts: PromptRequestOptions) => {
|
||||
promptCalls.push({
|
||||
sessionID: opts.path.id,
|
||||
agent: opts.body.agent,
|
||||
model: opts.body.model,
|
||||
text: opts.body.parts[0].text,
|
||||
})
|
||||
throw new Error("simulated auth failure")
|
||||
}
|
||||
const hook = createTodoContinuationEnforcer(mockInput, {})
|
||||
|
||||
//#when
|
||||
for (let index = 0; index < MAX_CONSECUTIVE_FAILURES; index++) {
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||
await fakeTimers.advanceBy(2500, true)
|
||||
if (index < MAX_CONSECUTIVE_FAILURES - 1) {
|
||||
await fakeTimers.advanceClockBy(1_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||
await fakeTimers.advanceBy(2500, true)
|
||||
|
||||
await fakeTimers.advanceClockBy(FAILURE_RESET_WINDOW_MS)
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||
await fakeTimers.advanceBy(2500, true)
|
||||
|
||||
//#then
|
||||
expect(promptCalls).toHaveLength(MAX_CONSECUTIVE_FAILURES + 1)
|
||||
}, { timeout: 30000 })
|
||||
|
||||
test("should increase cooldown exponentially after consecutive failures", async () => {
|
||||
//#given
|
||||
const sessionID = "main-exponential-backoff"
|
||||
setMainSession(sessionID)
|
||||
const mockInput = createMockPluginInput()
|
||||
mockInput.client.session.promptAsync = async (opts: PromptRequestOptions) => {
|
||||
promptCalls.push({
|
||||
sessionID: opts.path.id,
|
||||
agent: opts.body.agent,
|
||||
model: opts.body.model,
|
||||
text: opts.body.parts[0].text,
|
||||
})
|
||||
throw new Error("simulated auth failure")
|
||||
}
|
||||
const hook = createTodoContinuationEnforcer(mockInput, {})
|
||||
|
||||
//#when
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||
await fakeTimers.advanceBy(2500, true)
|
||||
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||
await fakeTimers.advanceBy(2500, true)
|
||||
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||
await fakeTimers.advanceBy(2500, true)
|
||||
|
||||
//#then
|
||||
expect(promptCalls).toHaveLength(2)
|
||||
}, { timeout: 30000 })
|
||||
|
||||
test("should reset consecutive failure count after successful injection", async () => {
|
||||
//#given
|
||||
const sessionID = "main-reset-consecutive-failures"
|
||||
setMainSession(sessionID)
|
||||
let shouldFail = true
|
||||
const mockInput = createMockPluginInput()
|
||||
mockInput.client.session.promptAsync = async (opts: PromptRequestOptions) => {
|
||||
promptCalls.push({
|
||||
sessionID: opts.path.id,
|
||||
agent: opts.body.agent,
|
||||
model: opts.body.model,
|
||||
text: opts.body.parts[0].text,
|
||||
})
|
||||
if (shouldFail) {
|
||||
shouldFail = false
|
||||
throw new Error("simulated auth failure")
|
||||
}
|
||||
return {}
|
||||
}
|
||||
const hook = createTodoContinuationEnforcer(mockInput, {})
|
||||
|
||||
//#when
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||
await fakeTimers.advanceBy(2500, true)
|
||||
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS * 2)
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||
await fakeTimers.advanceBy(2500, true)
|
||||
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||
await fakeTimers.advanceBy(2500, true)
|
||||
|
||||
//#then
|
||||
expect(promptCalls).toHaveLength(3)
|
||||
}, { timeout: 30000 })
|
||||
|
||||
test("should keep injecting even when todos remain unchanged across cycles", async () => {
|
||||
//#given
|
||||
const sessionID = "main-no-stagnation-cap"
|
||||
@@ -534,26 +736,26 @@ describe("todo-continuation-enforcer", () => {
|
||||
//#when — 5 consecutive idle cycles with unchanged todos
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||
await fakeTimers.advanceBy(2500, true)
|
||||
await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true)
|
||||
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
|
||||
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||
await fakeTimers.advanceBy(2500, true)
|
||||
await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true)
|
||||
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
|
||||
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||
await fakeTimers.advanceBy(2500, true)
|
||||
await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true)
|
||||
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
|
||||
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||
await fakeTimers.advanceBy(2500, true)
|
||||
await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true)
|
||||
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
|
||||
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||
await fakeTimers.advanceBy(2500, true)
|
||||
|
||||
//#then — all 5 injections should fire (no stagnation cap)
|
||||
expect(promptCalls).toHaveLength(5)
|
||||
})
|
||||
}, { timeout: 60000 })
|
||||
|
||||
test("should skip idle handling while injection is in flight", async () => {
|
||||
//#given
|
||||
@@ -613,7 +815,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
|
||||
//#then
|
||||
expect(promptCalls).toHaveLength(2)
|
||||
})
|
||||
}, { timeout: 15000 })
|
||||
|
||||
test("should accept skipAgents option without error", async () => {
|
||||
// given - session with skipAgents configured for Prometheus
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface SessionState {
|
||||
abortDetectedAt?: number
|
||||
lastInjectedAt?: number
|
||||
inFlight?: boolean
|
||||
consecutiveFailures: number
|
||||
}
|
||||
|
||||
export interface MessageInfo {
|
||||
|
||||
118
src/plugin/chat-message.test.ts
Normal file
118
src/plugin/chat-message.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
|
||||
import { createChatMessageHandler } from "./chat-message"
|
||||
|
||||
type ChatMessagePart = { type: string; text?: string; [key: string]: unknown }
|
||||
type ChatMessageHandlerOutput = { message: Record<string, unknown>; parts: ChatMessagePart[] }
|
||||
|
||||
function createMockHandlerArgs(overrides?: {
|
||||
pluginConfig?: Record<string, unknown>
|
||||
shouldOverride?: boolean
|
||||
}) {
|
||||
const appliedSessions: string[] = []
|
||||
return {
|
||||
ctx: { client: { tui: { showToast: async () => {} } } } as any,
|
||||
pluginConfig: (overrides?.pluginConfig ?? {}) as any,
|
||||
firstMessageVariantGate: {
|
||||
shouldOverride: () => overrides?.shouldOverride ?? false,
|
||||
markApplied: (sessionID: string) => { appliedSessions.push(sessionID) },
|
||||
},
|
||||
hooks: {
|
||||
stopContinuationGuard: null,
|
||||
keywordDetector: null,
|
||||
claudeCodeHooks: null,
|
||||
autoSlashCommand: null,
|
||||
startWork: null,
|
||||
ralphLoop: null,
|
||||
} as any,
|
||||
_appliedSessions: appliedSessions,
|
||||
}
|
||||
}
|
||||
|
||||
function createMockInput(agent?: string, model?: { providerID: string; modelID: string }) {
|
||||
return {
|
||||
sessionID: "test-session",
|
||||
agent,
|
||||
model,
|
||||
}
|
||||
}
|
||||
|
||||
function createMockOutput(variant?: string): ChatMessageHandlerOutput {
|
||||
const message: Record<string, unknown> = {}
|
||||
if (variant !== undefined) {
|
||||
message["variant"] = variant
|
||||
}
|
||||
return { message, parts: [] }
|
||||
}
|
||||
|
||||
describe("createChatMessageHandler - first message variant", () => {
|
||||
test("first message: sets variant from fallback chain when user has no selection", async () => {
|
||||
//#given - first message, no user-selected variant, hephaestus with medium in chain
|
||||
const args = createMockHandlerArgs({ shouldOverride: true })
|
||||
const handler = createChatMessageHandler(args)
|
||||
const input = createMockInput("hephaestus", { providerID: "openai", modelID: "gpt-5.3-codex" })
|
||||
const output = createMockOutput() // no variant set
|
||||
|
||||
//#when
|
||||
await handler(input, output)
|
||||
|
||||
//#then - should set variant from fallback chain
|
||||
expect(output.message["variant"]).toBeDefined()
|
||||
})
|
||||
|
||||
test("first message: preserves user-selected variant when already set", async () => {
|
||||
//#given - first message, user already selected "xhigh" variant in OpenCode UI
|
||||
const args = createMockHandlerArgs({ shouldOverride: true })
|
||||
const handler = createChatMessageHandler(args)
|
||||
const input = createMockInput("hephaestus", { providerID: "openai", modelID: "gpt-5.3-codex" })
|
||||
const output = createMockOutput("xhigh") // user selected xhigh
|
||||
|
||||
//#when
|
||||
await handler(input, output)
|
||||
|
||||
//#then - user's xhigh must be preserved, not overwritten to "medium"
|
||||
expect(output.message["variant"]).toBe("xhigh")
|
||||
})
|
||||
|
||||
test("first message: preserves user-selected 'high' variant", async () => {
|
||||
//#given - user selected "high" variant
|
||||
const args = createMockHandlerArgs({ shouldOverride: true })
|
||||
const handler = createChatMessageHandler(args)
|
||||
const input = createMockInput("hephaestus", { providerID: "openai", modelID: "gpt-5.3-codex" })
|
||||
const output = createMockOutput("high")
|
||||
|
||||
//#when
|
||||
await handler(input, output)
|
||||
|
||||
//#then
|
||||
expect(output.message["variant"]).toBe("high")
|
||||
})
|
||||
|
||||
test("subsequent message: does not override existing variant", async () => {
|
||||
//#given - not first message, variant already set
|
||||
const args = createMockHandlerArgs({ shouldOverride: false })
|
||||
const handler = createChatMessageHandler(args)
|
||||
const input = createMockInput("hephaestus", { providerID: "openai", modelID: "gpt-5.3-codex" })
|
||||
const output = createMockOutput("xhigh")
|
||||
|
||||
//#when
|
||||
await handler(input, output)
|
||||
|
||||
//#then
|
||||
expect(output.message["variant"]).toBe("xhigh")
|
||||
})
|
||||
|
||||
test("first message: marks gate as applied regardless of variant presence", async () => {
|
||||
//#given - first message with user-selected variant
|
||||
const args = createMockHandlerArgs({ shouldOverride: true })
|
||||
const handler = createChatMessageHandler(args)
|
||||
const input = createMockInput("hephaestus", { providerID: "openai", modelID: "gpt-5.3-codex" })
|
||||
const output = createMockOutput("xhigh")
|
||||
|
||||
//#when
|
||||
await handler(input, output)
|
||||
|
||||
//#then - gate should still be marked as applied
|
||||
expect(args._appliedSessions).toContain("test-session")
|
||||
})
|
||||
})
|
||||
@@ -56,12 +56,14 @@ export function createChatMessageHandler(args: {
|
||||
const message = output.message
|
||||
|
||||
if (firstMessageVariantGate.shouldOverride(input.sessionID)) {
|
||||
const variant =
|
||||
input.model && input.agent
|
||||
? resolveVariantForModel(pluginConfig, input.agent, input.model)
|
||||
: resolveAgentVariant(pluginConfig, input.agent)
|
||||
if (variant !== undefined) {
|
||||
message["variant"] = variant
|
||||
if (message["variant"] === undefined) {
|
||||
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 {
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
discoverProjectClaudeSkills,
|
||||
discoverOpencodeGlobalSkills,
|
||||
discoverOpencodeProjectSkills,
|
||||
discoverProjectAgentsSkills,
|
||||
discoverGlobalAgentsSkills,
|
||||
mergeSkills,
|
||||
} from "../features/opencode-skill-loader"
|
||||
import { createBuiltinSkills } from "../features/builtin-skills"
|
||||
@@ -55,7 +57,7 @@ export async function createSkillContext(args: {
|
||||
})
|
||||
|
||||
const includeClaudeSkills = pluginConfig.claude_code?.skills !== false
|
||||
const [configSourceSkills, userSkills, globalSkills, projectSkills, opencodeProjectSkills] =
|
||||
const [configSourceSkills, userSkills, globalSkills, projectSkills, opencodeProjectSkills, agentsProjectSkills, agentsGlobalSkills] =
|
||||
await Promise.all([
|
||||
discoverConfigSourceSkills({
|
||||
config: pluginConfig.skills,
|
||||
@@ -65,15 +67,17 @@ export async function createSkillContext(args: {
|
||||
discoverOpencodeGlobalSkills(),
|
||||
includeClaudeSkills ? discoverProjectClaudeSkills(directory) : Promise.resolve([]),
|
||||
discoverOpencodeProjectSkills(directory),
|
||||
discoverProjectAgentsSkills(directory),
|
||||
discoverGlobalAgentsSkills(),
|
||||
])
|
||||
|
||||
const mergedSkills = mergeSkills(
|
||||
builtinSkills,
|
||||
pluginConfig.skills,
|
||||
configSourceSkills,
|
||||
userSkills,
|
||||
[...userSkills, ...agentsGlobalSkills],
|
||||
globalSkills,
|
||||
projectSkills,
|
||||
[...projectSkills, ...agentsProjectSkills],
|
||||
opencodeProjectSkills,
|
||||
{ configDir: directory },
|
||||
)
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createToolRegistry } from "./tool-registry"
|
||||
import type { OhMyOpenCodeConfig } from "../config/schema"
|
||||
|
||||
describe("team system tool registration", () => {
|
||||
test("registers team tools when experimental.team_system is true", () => {
|
||||
const pluginConfig = {
|
||||
experimental: { team_system: true },
|
||||
} as unknown as OhMyOpenCodeConfig
|
||||
|
||||
const result = createToolRegistry({
|
||||
ctx: {} as any,
|
||||
pluginConfig,
|
||||
managers: {} as any,
|
||||
skillContext: {} as any,
|
||||
availableCategories: [],
|
||||
})
|
||||
|
||||
expect(Object.keys(result.filteredTools)).toContain("team_create")
|
||||
expect(Object.keys(result.filteredTools)).toContain("team_delete")
|
||||
expect(Object.keys(result.filteredTools)).toContain("send_message")
|
||||
expect(Object.keys(result.filteredTools)).toContain("read_inbox")
|
||||
expect(Object.keys(result.filteredTools)).toContain("read_config")
|
||||
expect(Object.keys(result.filteredTools)).toContain("force_kill_teammate")
|
||||
expect(Object.keys(result.filteredTools)).toContain("process_shutdown_approved")
|
||||
})
|
||||
|
||||
test("does not register team tools when experimental.team_system is false", () => {
|
||||
const pluginConfig = {
|
||||
experimental: { team_system: false },
|
||||
} as unknown as OhMyOpenCodeConfig
|
||||
|
||||
const result = createToolRegistry({
|
||||
ctx: {} as any,
|
||||
pluginConfig,
|
||||
managers: {} as any,
|
||||
skillContext: {} as any,
|
||||
availableCategories: [],
|
||||
})
|
||||
|
||||
expect(Object.keys(result.filteredTools)).not.toContain("team_create")
|
||||
expect(Object.keys(result.filteredTools)).not.toContain("team_delete")
|
||||
expect(Object.keys(result.filteredTools)).not.toContain("send_message")
|
||||
expect(Object.keys(result.filteredTools)).not.toContain("read_inbox")
|
||||
expect(Object.keys(result.filteredTools)).not.toContain("read_config")
|
||||
expect(Object.keys(result.filteredTools)).not.toContain("force_kill_teammate")
|
||||
expect(Object.keys(result.filteredTools)).not.toContain("process_shutdown_approved")
|
||||
})
|
||||
|
||||
test("does not register team tools when experimental.team_system is undefined", () => {
|
||||
const pluginConfig = {
|
||||
experimental: {},
|
||||
} as unknown as OhMyOpenCodeConfig
|
||||
|
||||
const result = createToolRegistry({
|
||||
ctx: {} as any,
|
||||
pluginConfig,
|
||||
managers: {} as any,
|
||||
skillContext: {} as any,
|
||||
availableCategories: [],
|
||||
})
|
||||
|
||||
expect(Object.keys(result.filteredTools)).not.toContain("team_create")
|
||||
expect(Object.keys(result.filteredTools)).not.toContain("team_delete")
|
||||
expect(Object.keys(result.filteredTools)).not.toContain("send_message")
|
||||
expect(Object.keys(result.filteredTools)).not.toContain("read_inbox")
|
||||
expect(Object.keys(result.filteredTools)).not.toContain("read_config")
|
||||
expect(Object.keys(result.filteredTools)).not.toContain("force_kill_teammate")
|
||||
expect(Object.keys(result.filteredTools)).not.toContain("process_shutdown_approved")
|
||||
})
|
||||
})
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
createAstGrepTools,
|
||||
createSessionManagerTools,
|
||||
createDelegateTask,
|
||||
createAgentTeamsTools,
|
||||
discoverCommandsSync,
|
||||
interactive_bash,
|
||||
createTaskCreateTool,
|
||||
@@ -118,15 +117,6 @@ export function createToolRegistry(args: {
|
||||
}
|
||||
: {}
|
||||
|
||||
const teamSystemEnabled = pluginConfig.experimental?.team_system ?? false
|
||||
const agentTeamsRecord: Record<string, ToolDefinition> = teamSystemEnabled
|
||||
? createAgentTeamsTools(managers.backgroundManager, {
|
||||
client: ctx.client,
|
||||
userCategories: pluginConfig.categories,
|
||||
sisyphusJuniorModel: pluginConfig.agents?.["sisyphus-junior"]?.model,
|
||||
})
|
||||
: {}
|
||||
|
||||
const allTools: Record<string, ToolDefinition> = {
|
||||
...builtinTools,
|
||||
...createGrepTools(ctx),
|
||||
@@ -142,7 +132,6 @@ export function createToolRegistry(args: {
|
||||
slashcommand: slashcommandTool,
|
||||
interactive_bash,
|
||||
...taskToolsRecord,
|
||||
...agentTeamsRecord,
|
||||
}
|
||||
|
||||
const filteredTools = filterDisabledTools(allTools, pluginConfig.disabled_tools)
|
||||
|
||||
103
src/shared/file-utils.test.ts
Normal file
103
src/shared/file-utils.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "bun:test"
|
||||
import { mkdirSync, writeFileSync, symlinkSync, rmSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { tmpdir } from "os"
|
||||
import { resolveSymlink, resolveSymlinkAsync, isSymbolicLink } from "./file-utils"
|
||||
|
||||
const testDir = join(tmpdir(), "file-utils-test-" + Date.now())
|
||||
|
||||
// Create a directory structure that mimics the real-world scenario:
|
||||
//
|
||||
// testDir/
|
||||
// ├── repo/
|
||||
// │ ├── skills/
|
||||
// │ │ └── category/
|
||||
// │ │ └── my-skill/
|
||||
// │ │ └── SKILL.md
|
||||
// │ └── .opencode/
|
||||
// │ └── skills/
|
||||
// │ └── my-skill -> ../../skills/category/my-skill (relative symlink)
|
||||
// └── config/
|
||||
// └── skills -> ../repo/.opencode/skills (absolute symlink)
|
||||
|
||||
const realSkillDir = join(testDir, "repo", "skills", "category", "my-skill")
|
||||
const repoOpencodeSkills = join(testDir, "repo", ".opencode", "skills")
|
||||
const configSkills = join(testDir, "config", "skills")
|
||||
|
||||
beforeAll(() => {
|
||||
// Create real skill directory with a file
|
||||
mkdirSync(realSkillDir, { recursive: true })
|
||||
writeFileSync(join(realSkillDir, "SKILL.md"), "# My Skill")
|
||||
|
||||
// Create .opencode/skills/ with a relative symlink to the real skill
|
||||
mkdirSync(repoOpencodeSkills, { recursive: true })
|
||||
symlinkSync("../../skills/category/my-skill", join(repoOpencodeSkills, "my-skill"))
|
||||
|
||||
// Create config/skills as an absolute symlink to .opencode/skills
|
||||
mkdirSync(join(testDir, "config"), { recursive: true })
|
||||
symlinkSync(repoOpencodeSkills, configSkills)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(testDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("resolveSymlink", () => {
|
||||
it("resolves a regular file path to itself", () => {
|
||||
const filePath = join(realSkillDir, "SKILL.md")
|
||||
expect(resolveSymlink(filePath)).toBe(filePath)
|
||||
})
|
||||
|
||||
it("resolves a relative symlink to its real path", () => {
|
||||
const symlinkPath = join(repoOpencodeSkills, "my-skill")
|
||||
expect(resolveSymlink(symlinkPath)).toBe(realSkillDir)
|
||||
})
|
||||
|
||||
it("resolves a chained symlink (symlink-to-dir-containing-symlinks) to the real path", () => {
|
||||
// This is the real-world scenario:
|
||||
// config/skills/my-skill -> (follows config/skills) -> repo/.opencode/skills/my-skill -> repo/skills/category/my-skill
|
||||
const chainedPath = join(configSkills, "my-skill")
|
||||
expect(resolveSymlink(chainedPath)).toBe(realSkillDir)
|
||||
})
|
||||
|
||||
it("returns the original path for non-existent paths", () => {
|
||||
const fakePath = join(testDir, "does-not-exist")
|
||||
expect(resolveSymlink(fakePath)).toBe(fakePath)
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveSymlinkAsync", () => {
|
||||
it("resolves a regular file path to itself", async () => {
|
||||
const filePath = join(realSkillDir, "SKILL.md")
|
||||
expect(await resolveSymlinkAsync(filePath)).toBe(filePath)
|
||||
})
|
||||
|
||||
it("resolves a relative symlink to its real path", async () => {
|
||||
const symlinkPath = join(repoOpencodeSkills, "my-skill")
|
||||
expect(await resolveSymlinkAsync(symlinkPath)).toBe(realSkillDir)
|
||||
})
|
||||
|
||||
it("resolves a chained symlink (symlink-to-dir-containing-symlinks) to the real path", async () => {
|
||||
const chainedPath = join(configSkills, "my-skill")
|
||||
expect(await resolveSymlinkAsync(chainedPath)).toBe(realSkillDir)
|
||||
})
|
||||
|
||||
it("returns the original path for non-existent paths", async () => {
|
||||
const fakePath = join(testDir, "does-not-exist")
|
||||
expect(await resolveSymlinkAsync(fakePath)).toBe(fakePath)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isSymbolicLink", () => {
|
||||
it("returns true for a symlink", () => {
|
||||
expect(isSymbolicLink(join(repoOpencodeSkills, "my-skill"))).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false for a regular directory", () => {
|
||||
expect(isSymbolicLink(realSkillDir)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns false for a non-existent path", () => {
|
||||
expect(isSymbolicLink(join(testDir, "does-not-exist"))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,9 @@
|
||||
import { lstatSync, readlinkSync } from "fs"
|
||||
import { lstatSync, realpathSync } from "fs"
|
||||
import { promises as fs } from "fs"
|
||||
import { resolve } from "path"
|
||||
|
||||
function normalizeDarwinRealpath(filePath: string): string {
|
||||
return filePath.startsWith("/private/var/") ? filePath.slice("/private".length) : filePath
|
||||
}
|
||||
|
||||
export function isMarkdownFile(entry: { name: string; isFile: () => boolean }): boolean {
|
||||
return !entry.name.startsWith(".") && entry.name.endsWith(".md") && entry.isFile()
|
||||
@@ -16,11 +19,7 @@ export function isSymbolicLink(filePath: string): boolean {
|
||||
|
||||
export function resolveSymlink(filePath: string): string {
|
||||
try {
|
||||
const stats = lstatSync(filePath, { throwIfNoEntry: false })
|
||||
if (stats?.isSymbolicLink()) {
|
||||
return resolve(filePath, "..", readlinkSync(filePath))
|
||||
}
|
||||
return filePath
|
||||
return normalizeDarwinRealpath(realpathSync(filePath))
|
||||
} catch {
|
||||
return filePath
|
||||
}
|
||||
@@ -28,12 +27,7 @@ export function resolveSymlink(filePath: string): string {
|
||||
|
||||
export async function resolveSymlinkAsync(filePath: string): Promise<string> {
|
||||
try {
|
||||
const stats = await fs.lstat(filePath)
|
||||
if (stats.isSymbolicLink()) {
|
||||
const linkTarget = await fs.readlink(filePath)
|
||||
return resolve(filePath, "..", linkTarget)
|
||||
}
|
||||
return filePath
|
||||
return normalizeDarwinRealpath(await fs.realpath(filePath))
|
||||
} catch {
|
||||
return filePath
|
||||
}
|
||||
|
||||
@@ -241,19 +241,32 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
|
||||
expect(primary.providers[0]).toBe("openai")
|
||||
})
|
||||
|
||||
test("visual-engineering has valid fallbackChain with gemini-3-pro as primary", () => {
|
||||
test("visual-engineering has valid fallbackChain with gemini-3-pro high as primary", () => {
|
||||
// given - visual-engineering category requirement
|
||||
const visualEngineering = CATEGORY_MODEL_REQUIREMENTS["visual-engineering"]
|
||||
|
||||
// when - accessing visual-engineering requirement
|
||||
// then - fallbackChain exists with gemini-3-pro as first entry
|
||||
// then - fallbackChain: gemini-3-pro(high) → glm-5 → opus-4-6(max) → k2p5
|
||||
expect(visualEngineering).toBeDefined()
|
||||
expect(visualEngineering.fallbackChain).toBeArray()
|
||||
expect(visualEngineering.fallbackChain.length).toBeGreaterThan(0)
|
||||
expect(visualEngineering.fallbackChain).toHaveLength(4)
|
||||
|
||||
const primary = visualEngineering.fallbackChain[0]
|
||||
expect(primary.providers[0]).toBe("google")
|
||||
expect(primary.model).toBe("gemini-3-pro")
|
||||
expect(primary.variant).toBe("high")
|
||||
|
||||
const second = visualEngineering.fallbackChain[1]
|
||||
expect(second.providers[0]).toBe("zai-coding-plan")
|
||||
expect(second.model).toBe("glm-5")
|
||||
|
||||
const third = visualEngineering.fallbackChain[2]
|
||||
expect(third.model).toBe("claude-opus-4-6")
|
||||
expect(third.variant).toBe("max")
|
||||
|
||||
const fourth = visualEngineering.fallbackChain[3]
|
||||
expect(fourth.providers[0]).toBe("kimi-for-coding")
|
||||
expect(fourth.model).toBe("k2p5")
|
||||
})
|
||||
|
||||
test("quick has valid fallbackChain with claude-haiku-4-5 as primary", () => {
|
||||
@@ -318,19 +331,23 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
|
||||
expect(primary.providers[0]).toBe("google")
|
||||
})
|
||||
|
||||
test("writing has valid fallbackChain with gemini-3-flash as primary", () => {
|
||||
test("writing has valid fallbackChain with k2p5 as primary (kimi-for-coding)", () => {
|
||||
// given - writing category requirement
|
||||
const writing = CATEGORY_MODEL_REQUIREMENTS["writing"]
|
||||
|
||||
// when - accessing writing requirement
|
||||
// then - fallbackChain exists with gemini-3-flash as first entry
|
||||
// then - fallbackChain: k2p5 → gemini-3-flash → claude-sonnet-4-5
|
||||
expect(writing).toBeDefined()
|
||||
expect(writing.fallbackChain).toBeArray()
|
||||
expect(writing.fallbackChain.length).toBeGreaterThan(0)
|
||||
expect(writing.fallbackChain).toHaveLength(3)
|
||||
|
||||
const primary = writing.fallbackChain[0]
|
||||
expect(primary.model).toBe("gemini-3-flash")
|
||||
expect(primary.providers[0]).toBe("google")
|
||||
expect(primary.model).toBe("k2p5")
|
||||
expect(primary.providers[0]).toBe("kimi-for-coding")
|
||||
|
||||
const second = writing.fallbackChain[1]
|
||||
expect(second.model).toBe("gemini-3-flash")
|
||||
expect(second.providers[0]).toBe("google")
|
||||
})
|
||||
|
||||
test("all 8 categories have valid fallbackChain arrays", () => {
|
||||
|
||||
@@ -100,9 +100,10 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
"visual-engineering": {
|
||||
fallbackChain: [
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
||||
{ providers: ["zai-coding-plan"], model: "glm-5" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||
{ providers: ["zai-coding-plan"], model: "glm-4.7" },
|
||||
{ providers: ["kimi-for-coding"], model: "k2p5" },
|
||||
],
|
||||
},
|
||||
ultrabrain: {
|
||||
@@ -151,10 +152,9 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
},
|
||||
writing: {
|
||||
fallbackChain: [
|
||||
{ providers: ["kimi-for-coding"], model: "k2p5" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" },
|
||||
{ providers: ["zai-coding-plan"], model: "glm-4.7" },
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
72
src/shared/session-tools-store.test.ts
Normal file
72
src/shared/session-tools-store.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, test, expect, beforeEach } from "bun:test"
|
||||
import { setSessionTools, getSessionTools, clearSessionTools } from "./session-tools-store"
|
||||
|
||||
describe("session-tools-store", () => {
|
||||
beforeEach(() => {
|
||||
clearSessionTools()
|
||||
})
|
||||
|
||||
test("returns undefined for unknown session", () => {
|
||||
//#given
|
||||
const sessionID = "ses_unknown"
|
||||
|
||||
//#when
|
||||
const result = getSessionTools(sessionID)
|
||||
|
||||
//#then
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
test("stores and retrieves tools for a session", () => {
|
||||
//#given
|
||||
const sessionID = "ses_abc123"
|
||||
const tools = { question: false, task: true, call_omo_agent: true }
|
||||
|
||||
//#when
|
||||
setSessionTools(sessionID, tools)
|
||||
const result = getSessionTools(sessionID)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual({ question: false, task: true, call_omo_agent: true })
|
||||
})
|
||||
|
||||
test("overwrites existing tools for same session", () => {
|
||||
//#given
|
||||
const sessionID = "ses_abc123"
|
||||
setSessionTools(sessionID, { question: false })
|
||||
|
||||
//#when
|
||||
setSessionTools(sessionID, { question: true, task: false })
|
||||
const result = getSessionTools(sessionID)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual({ question: true, task: false })
|
||||
})
|
||||
|
||||
test("clearSessionTools removes all entries", () => {
|
||||
//#given
|
||||
setSessionTools("ses_1", { question: false })
|
||||
setSessionTools("ses_2", { task: true })
|
||||
|
||||
//#when
|
||||
clearSessionTools()
|
||||
|
||||
//#then
|
||||
expect(getSessionTools("ses_1")).toBeUndefined()
|
||||
expect(getSessionTools("ses_2")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns a copy, not a reference", () => {
|
||||
//#given
|
||||
const sessionID = "ses_abc123"
|
||||
const tools = { question: false }
|
||||
setSessionTools(sessionID, tools)
|
||||
|
||||
//#when
|
||||
const result = getSessionTools(sessionID)!
|
||||
result.question = true
|
||||
|
||||
//#then
|
||||
expect(getSessionTools(sessionID)).toEqual({ question: false })
|
||||
})
|
||||
})
|
||||
14
src/shared/session-tools-store.ts
Normal file
14
src/shared/session-tools-store.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
const store = new Map<string, Record<string, boolean>>()
|
||||
|
||||
export function setSessionTools(sessionID: string, tools: Record<string, boolean>): void {
|
||||
store.set(sessionID, { ...tools })
|
||||
}
|
||||
|
||||
export function getSessionTools(sessionID: string): Record<string, boolean> | undefined {
|
||||
const tools = store.get(sessionID)
|
||||
return tools ? { ...tools } : undefined
|
||||
}
|
||||
|
||||
export function clearSessionTools(): void {
|
||||
store.clear()
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import { mkdtempSync, rmSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { createTeamConfig, deleteTeamData } from "./team-config-store"
|
||||
import { createReadConfigTool } from "./config-tools"
|
||||
|
||||
describe("read_config tool", () => {
|
||||
let originalCwd: string
|
||||
let tempProjectDir: string
|
||||
let teamName: string
|
||||
const TEST_SESSION_ID = "test-session-123"
|
||||
const TEST_ABORT_CONTROLLER = new AbortController()
|
||||
const TEST_CONTEXT = {
|
||||
sessionID: TEST_SESSION_ID,
|
||||
messageID: "test-message-123",
|
||||
agent: "test-agent",
|
||||
abort: TEST_ABORT_CONTROLLER.signal,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd()
|
||||
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-config-tools-"))
|
||||
process.chdir(tempProjectDir)
|
||||
teamName = `test-team-${randomUUID()}`
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
deleteTeamData(teamName)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
process.chdir(originalCwd)
|
||||
rmSync(tempProjectDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("read config action", () => {
|
||||
test("returns team config when team exists", async () => {
|
||||
//#given
|
||||
const config = createTeamConfig(teamName, "Test team", TEST_SESSION_ID, "/tmp", "claude-opus-4-6")
|
||||
const tool = createReadConfigTool()
|
||||
|
||||
//#when
|
||||
const resultStr = await tool.execute({
|
||||
team_name: teamName,
|
||||
}, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result.name).toBe(teamName)
|
||||
expect(result.description).toBe("Test team")
|
||||
expect(result.members).toHaveLength(1)
|
||||
expect(result.members[0].name).toBe("team-lead")
|
||||
expect(result.members[0].agentType).toBe("team-lead")
|
||||
})
|
||||
|
||||
test("returns error for non-existent team", async () => {
|
||||
//#given
|
||||
const tool = createReadConfigTool()
|
||||
|
||||
//#when
|
||||
const resultStr = await tool.execute({
|
||||
team_name: "nonexistent-team-12345",
|
||||
}, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveProperty("error")
|
||||
expect(result.error).toBe("team_not_found")
|
||||
})
|
||||
|
||||
test("requires team_name parameter", async () => {
|
||||
//#given
|
||||
const tool = createReadConfigTool()
|
||||
|
||||
//#when
|
||||
const resultStr = await tool.execute({}, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveProperty("error")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,24 +0,0 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||
import { readTeamConfig } from "./team-config-store"
|
||||
import { ReadConfigInputSchema } from "./types"
|
||||
|
||||
export function createReadConfigTool(): ToolDefinition {
|
||||
return tool({
|
||||
description: "Read team configuration and member list.",
|
||||
args: {
|
||||
team_name: tool.schema.string().describe("Team name"),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>): Promise<string> => {
|
||||
try {
|
||||
const input = ReadConfigInputSchema.parse(args)
|
||||
const config = readTeamConfig(input.team_name)
|
||||
if (!config) {
|
||||
return JSON.stringify({ error: "team_not_found" })
|
||||
}
|
||||
return JSON.stringify(config)
|
||||
} catch (error) {
|
||||
return JSON.stringify({ error: error instanceof Error ? error.message : "read_config_failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import { mkdtempSync, rmSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import { createAgentTeamsTools } from "./tools"
|
||||
|
||||
interface LaunchCall {
|
||||
description: string
|
||||
prompt: string
|
||||
agent: string
|
||||
parentSessionID: string
|
||||
parentMessageID: string
|
||||
parentAgent?: string
|
||||
parentModel?: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
variant?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface ResumeCall {
|
||||
sessionId: string
|
||||
prompt: string
|
||||
parentSessionID: string
|
||||
parentMessageID: string
|
||||
parentAgent?: string
|
||||
parentModel?: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
variant?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface ToolContextLike {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
abort: AbortSignal
|
||||
agent?: string
|
||||
}
|
||||
|
||||
function createMockManager(): {
|
||||
manager: BackgroundManager
|
||||
launchCalls: LaunchCall[]
|
||||
resumeCalls: ResumeCall[]
|
||||
} {
|
||||
const launchCalls: LaunchCall[] = []
|
||||
const resumeCalls: ResumeCall[] = []
|
||||
const launchedTasks = new Map<string, { id: string; sessionID: string }>()
|
||||
let launchCount = 0
|
||||
|
||||
const manager = {
|
||||
launch: async (args: LaunchCall) => {
|
||||
launchCount += 1
|
||||
launchCalls.push(args)
|
||||
const task = { id: `bg-${launchCount}`, sessionID: `ses-worker-${launchCount}` }
|
||||
launchedTasks.set(task.id, task)
|
||||
return task
|
||||
},
|
||||
getTask: (taskId: string) => launchedTasks.get(taskId),
|
||||
resume: async (args: ResumeCall) => {
|
||||
resumeCalls.push(args)
|
||||
return { id: `resume-${resumeCalls.length}` }
|
||||
},
|
||||
} as unknown as BackgroundManager
|
||||
|
||||
return { manager, launchCalls, resumeCalls }
|
||||
}
|
||||
|
||||
async function executeJsonTool(
|
||||
tools: ReturnType<typeof createAgentTeamsTools>,
|
||||
toolName: keyof ReturnType<typeof createAgentTeamsTools>,
|
||||
args: Record<string, unknown>,
|
||||
context: ToolContextLike,
|
||||
): Promise<unknown> {
|
||||
const output = await tools[toolName].execute(args, context as any)
|
||||
return JSON.parse(output)
|
||||
}
|
||||
|
||||
describe("agent-teams delegation consistency", () => {
|
||||
let originalCwd: string
|
||||
let tempProjectDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd()
|
||||
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-consistency-"))
|
||||
process.chdir(tempProjectDir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd)
|
||||
rmSync(tempProjectDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test("team delegation forwards parent context like normal delegate-task", async () => {
|
||||
//#given
|
||||
const { manager, launchCalls, resumeCalls } = createMockManager()
|
||||
const tools = createAgentTeamsTools(manager)
|
||||
const leadContext: ToolContextLike = {
|
||||
sessionID: "ses-main",
|
||||
messageID: "msg-main",
|
||||
abort: new AbortController().signal,
|
||||
agent: "sisyphus",
|
||||
}
|
||||
|
||||
await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext)
|
||||
|
||||
//#when
|
||||
const spawnResult = await executeJsonTool(
|
||||
tools,
|
||||
"spawn_teammate",
|
||||
{
|
||||
team_name: "core",
|
||||
name: "worker_1",
|
||||
prompt: "Handle release prep",
|
||||
category: "quick",
|
||||
},
|
||||
leadContext,
|
||||
) as { error?: string }
|
||||
|
||||
//#then
|
||||
expect(spawnResult.error).toBeUndefined()
|
||||
expect(launchCalls).toHaveLength(1)
|
||||
expect(launchCalls[0].parentAgent).toBe("sisyphus")
|
||||
expect("parentModel" in launchCalls[0]).toBe(true)
|
||||
|
||||
//#when
|
||||
const messageResult = await executeJsonTool(
|
||||
tools,
|
||||
"send_message",
|
||||
{
|
||||
team_name: "core",
|
||||
type: "message",
|
||||
recipient: "worker_1",
|
||||
summary: "sync",
|
||||
content: "Please update status.",
|
||||
},
|
||||
leadContext,
|
||||
) as { error?: string }
|
||||
|
||||
//#then
|
||||
expect(messageResult.error).toBeUndefined()
|
||||
expect(resumeCalls).toHaveLength(1)
|
||||
expect(resumeCalls[0].parentAgent).toBe("sisyphus")
|
||||
expect("parentModel" in resumeCalls[0]).toBe(true)
|
||||
})
|
||||
|
||||
test("send_message accepts teammate agent_id as recipient", async () => {
|
||||
//#given
|
||||
const { manager, resumeCalls } = createMockManager()
|
||||
const tools = createAgentTeamsTools(manager)
|
||||
const leadContext: ToolContextLike = {
|
||||
sessionID: "ses-main",
|
||||
messageID: "msg-main",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext)
|
||||
await executeJsonTool(
|
||||
tools,
|
||||
"spawn_teammate",
|
||||
{
|
||||
team_name: "core",
|
||||
name: "worker_1",
|
||||
prompt: "Handle release prep",
|
||||
category: "quick",
|
||||
},
|
||||
leadContext,
|
||||
)
|
||||
|
||||
//#when
|
||||
const messageResult = await executeJsonTool(
|
||||
tools,
|
||||
"send_message",
|
||||
{
|
||||
team_name: "core",
|
||||
type: "message",
|
||||
recipient: "worker_1@core",
|
||||
summary: "sync",
|
||||
content: "Please update status.",
|
||||
},
|
||||
leadContext,
|
||||
) as { error?: string }
|
||||
|
||||
//#then
|
||||
expect(messageResult.error).toBeUndefined()
|
||||
expect(resumeCalls).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -1,61 +0,0 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { InboxMessageSchema } from "./types"
|
||||
import { appendInboxMessage } from "./inbox-store"
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
|
||||
const STRUCTURED_TYPE_MAP: Record<string, string> = {
|
||||
shutdown_request: "shutdown_request",
|
||||
shutdown_approved: "shutdown_response",
|
||||
shutdown_rejected: "shutdown_response",
|
||||
plan_approved: "plan_approval_response",
|
||||
plan_rejected: "plan_approval_response",
|
||||
}
|
||||
|
||||
export function buildShutdownRequestId(recipient: string): string {
|
||||
return `shutdown-${recipient}-${randomUUID().slice(0, 8)}`
|
||||
}
|
||||
|
||||
export function sendPlainInboxMessage(
|
||||
teamName: string,
|
||||
sender: string,
|
||||
recipient: string,
|
||||
content: string,
|
||||
summary: string,
|
||||
_color?: string,
|
||||
): void {
|
||||
const message = InboxMessageSchema.parse({
|
||||
id: randomUUID(),
|
||||
type: "message",
|
||||
sender,
|
||||
recipient,
|
||||
content,
|
||||
summary,
|
||||
timestamp: nowIso(),
|
||||
read: false,
|
||||
})
|
||||
appendInboxMessage(teamName, recipient, message)
|
||||
}
|
||||
|
||||
export function sendStructuredInboxMessage(
|
||||
teamName: string,
|
||||
sender: string,
|
||||
recipient: string,
|
||||
data: Record<string, unknown>,
|
||||
summaryType: string,
|
||||
): void {
|
||||
const messageType = STRUCTURED_TYPE_MAP[summaryType] ?? "message"
|
||||
const message = InboxMessageSchema.parse({
|
||||
id: randomUUID(),
|
||||
type: messageType,
|
||||
sender,
|
||||
recipient,
|
||||
content: JSON.stringify(data),
|
||||
summary: summaryType,
|
||||
timestamp: nowIso(),
|
||||
read: false,
|
||||
})
|
||||
appendInboxMessage(teamName, recipient, message)
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { appendInboxMessage, ensureInbox, readInbox } from "./inbox-store"
|
||||
import { getTeamInboxPath } from "./paths"
|
||||
|
||||
describe("agent-teams inbox store", () => {
|
||||
let originalCwd: string
|
||||
let tempProjectDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd()
|
||||
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-inbox-store-"))
|
||||
process.chdir(tempProjectDir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd)
|
||||
rmSync(tempProjectDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test("readInbox fails on malformed inbox JSON without overwriting file", () => {
|
||||
//#given
|
||||
ensureInbox("core", "team-lead")
|
||||
const inboxPath = getTeamInboxPath("core", "team-lead")
|
||||
writeFileSync(inboxPath, "{", "utf-8")
|
||||
|
||||
//#when
|
||||
const readMalformedInbox = () => readInbox("core", "team-lead", false, false)
|
||||
|
||||
//#then
|
||||
expect(readMalformedInbox).toThrow("team_inbox_parse_failed")
|
||||
expect(readFileSync(inboxPath, "utf-8")).toBe("{")
|
||||
})
|
||||
|
||||
test("appendInboxMessage fails on schema-invalid inbox JSON without overwriting file", () => {
|
||||
//#given
|
||||
ensureInbox("core", "team-lead")
|
||||
const inboxPath = getTeamInboxPath("core", "team-lead")
|
||||
writeFileSync(inboxPath, JSON.stringify({ invalid: true }), "utf-8")
|
||||
|
||||
//#when
|
||||
const appendIntoInvalidInbox = () => {
|
||||
appendInboxMessage("core", "team-lead", {
|
||||
from: "team-lead",
|
||||
text: "hello",
|
||||
timestamp: new Date().toISOString(),
|
||||
read: false,
|
||||
summary: "note",
|
||||
})
|
||||
}
|
||||
|
||||
//#then
|
||||
expect(appendIntoInvalidInbox).toThrow("team_inbox_schema_invalid")
|
||||
expect(readFileSync(inboxPath, "utf-8")).toBe(JSON.stringify({ invalid: true }))
|
||||
})
|
||||
})
|
||||
@@ -1,197 +0,0 @@
|
||||
import { existsSync, readFileSync, unlinkSync } from "node:fs"
|
||||
import { z } from "zod"
|
||||
import { acquireLock, ensureDir, writeJsonAtomic } from "../../features/claude-tasks/storage"
|
||||
import { getTeamInboxPath } from "./paths"
|
||||
import type { InboxMessage } from "./types"
|
||||
import { InboxMessageSchema } from "./types"
|
||||
|
||||
const InboxMessageListSchema = z.array(InboxMessageSchema)
|
||||
|
||||
function assertValidTeamName(teamName: string): void {
|
||||
const errors: string[] = []
|
||||
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(teamName)) {
|
||||
errors.push("Team name must contain only letters, numbers, hyphens, and underscores")
|
||||
}
|
||||
if (teamName.length > 64) {
|
||||
errors.push("Team name must be at most 64 characters")
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Invalid team name: ${errors.join(", ")}`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertValidAgentName(agentName: string): void {
|
||||
if (!agentName || agentName.length === 0) {
|
||||
throw new Error("Agent name must not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
function getTeamInboxDirFromName(teamName: string): string {
|
||||
const { dirname } = require("node:path")
|
||||
return dirname(getTeamInboxPath(teamName, "dummy"))
|
||||
}
|
||||
|
||||
function withInboxLock<T>(teamName: string, operation: () => T): T {
|
||||
assertValidTeamName(teamName)
|
||||
const inboxDir = getTeamInboxDirFromName(teamName)
|
||||
ensureDir(inboxDir)
|
||||
const lock = acquireLock(inboxDir)
|
||||
|
||||
if (!lock.acquired) {
|
||||
throw new Error("inbox_lock_unavailable")
|
||||
}
|
||||
|
||||
try {
|
||||
return operation()
|
||||
} finally {
|
||||
lock.release()
|
||||
}
|
||||
}
|
||||
|
||||
function parseInboxFile(content: string): InboxMessage[] {
|
||||
let parsed: unknown
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(content)
|
||||
} catch {
|
||||
throw new Error("team_inbox_parse_failed")
|
||||
}
|
||||
|
||||
const result = InboxMessageListSchema.safeParse(parsed)
|
||||
if (!result.success) {
|
||||
throw new Error("team_inbox_schema_invalid")
|
||||
}
|
||||
|
||||
return result.data
|
||||
}
|
||||
|
||||
function readInboxMessages(teamName: string, agentName: string): InboxMessage[] {
|
||||
assertValidTeamName(teamName)
|
||||
assertValidAgentName(agentName)
|
||||
const path = getTeamInboxPath(teamName, agentName)
|
||||
|
||||
if (!existsSync(path)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return parseInboxFile(readFileSync(path, "utf-8"))
|
||||
}
|
||||
|
||||
function writeInboxMessages(teamName: string, agentName: string, messages: InboxMessage[]): void {
|
||||
assertValidTeamName(teamName)
|
||||
assertValidAgentName(agentName)
|
||||
const path = getTeamInboxPath(teamName, agentName)
|
||||
writeJsonAtomic(path, messages)
|
||||
}
|
||||
|
||||
export function ensureInbox(teamName: string, agentName: string): void {
|
||||
assertValidTeamName(teamName)
|
||||
assertValidAgentName(agentName)
|
||||
|
||||
withInboxLock(teamName, () => {
|
||||
const path = getTeamInboxPath(teamName, agentName)
|
||||
|
||||
if (!existsSync(path)) {
|
||||
writeJsonAtomic(path, [])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function appendInboxMessage(teamName: string, agentName: string, message: InboxMessage): void {
|
||||
assertValidTeamName(teamName)
|
||||
assertValidAgentName(agentName)
|
||||
|
||||
withInboxLock(teamName, () => {
|
||||
const path = getTeamInboxPath(teamName, agentName)
|
||||
const messages = existsSync(path) ? parseInboxFile(readFileSync(path, "utf-8")) : []
|
||||
messages.push(InboxMessageSchema.parse(message))
|
||||
writeInboxMessages(teamName, agentName, messages)
|
||||
})
|
||||
}
|
||||
|
||||
export function readInbox(teamName: string, agentName: string, unreadOnly = false, markAsRead = false): InboxMessage[] {
|
||||
return withInboxLock(teamName, () => {
|
||||
const messages = readInboxMessages(teamName, agentName)
|
||||
|
||||
const selectedIndexes = new Set<number>()
|
||||
|
||||
const selected = unreadOnly
|
||||
? messages.filter((message, index) => {
|
||||
if (!message.read) {
|
||||
selectedIndexes.add(index)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
: messages.map((message, index) => {
|
||||
selectedIndexes.add(index)
|
||||
return message
|
||||
})
|
||||
|
||||
if (!markAsRead || selected.length === 0) {
|
||||
return selected
|
||||
}
|
||||
|
||||
let changed = false
|
||||
|
||||
const updated = messages.map((message, index) => {
|
||||
if (selectedIndexes.has(index) && !message.read) {
|
||||
changed = true
|
||||
return { ...message, read: true }
|
||||
}
|
||||
return message
|
||||
})
|
||||
|
||||
if (changed) {
|
||||
writeInboxMessages(teamName, agentName, updated)
|
||||
}
|
||||
|
||||
return updated.filter((_, index) => selectedIndexes.has(index))
|
||||
})
|
||||
}
|
||||
|
||||
export function markMessagesRead(teamName: string, agentName: string, messageIds: string[]): void {
|
||||
assertValidTeamName(teamName)
|
||||
assertValidAgentName(agentName)
|
||||
|
||||
if (messageIds.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
withInboxLock(teamName, () => {
|
||||
const messages = readInboxMessages(teamName, agentName)
|
||||
const idsToMark = new Set(messageIds)
|
||||
|
||||
const updated = messages.map((message) => {
|
||||
if (idsToMark.has(message.id) && !message.read) {
|
||||
return { ...message, read: true }
|
||||
}
|
||||
return message
|
||||
})
|
||||
|
||||
const changed = updated.some((msg, index) => msg.read !== messages[index].read)
|
||||
|
||||
if (changed) {
|
||||
writeInboxMessages(teamName, agentName, updated)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteInbox(teamName: string, agentName: string): void {
|
||||
assertValidTeamName(teamName)
|
||||
assertValidAgentName(agentName)
|
||||
|
||||
withInboxLock(teamName, () => {
|
||||
const path = getTeamInboxPath(teamName, agentName)
|
||||
|
||||
if (existsSync(path)) {
|
||||
unlinkSync(path)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const clearInbox = deleteInbox
|
||||
|
||||
export { buildShutdownRequestId, sendPlainInboxMessage, sendStructuredInboxMessage } from "./inbox-message-sender"
|
||||
@@ -1,182 +0,0 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import { mkdtempSync, rmSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { appendInboxMessage, ensureInbox } from "./inbox-store"
|
||||
import { deleteTeamData } from "./team-config-store"
|
||||
import { createReadInboxTool } from "./inbox-tools"
|
||||
|
||||
describe("read_inbox tool", () => {
|
||||
let originalCwd: string
|
||||
let tempProjectDir: string
|
||||
let teamName: string
|
||||
const TEST_SESSION_ID = "test-session-123"
|
||||
const TEST_ABORT_CONTROLLER = new AbortController()
|
||||
const TEST_CONTEXT = {
|
||||
sessionID: TEST_SESSION_ID,
|
||||
messageID: "test-message-123",
|
||||
agent: "test-agent",
|
||||
abort: TEST_ABORT_CONTROLLER.signal,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd()
|
||||
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-inbox-tools-"))
|
||||
process.chdir(tempProjectDir)
|
||||
teamName = `test-team-${randomUUID()}`
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
deleteTeamData(teamName)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
process.chdir(originalCwd)
|
||||
rmSync(tempProjectDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd)
|
||||
rmSync(tempProjectDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("read inbox action", () => {
|
||||
test("returns all messages when no filters", async () => {
|
||||
//#given
|
||||
ensureInbox(teamName, "team-lead")
|
||||
appendInboxMessage(teamName, "team-lead", {
|
||||
id: "msg-1",
|
||||
type: "message",
|
||||
sender: "user",
|
||||
recipient: "team-lead",
|
||||
content: "Hello",
|
||||
timestamp: new Date().toISOString(),
|
||||
read: false,
|
||||
})
|
||||
appendInboxMessage(teamName, "team-lead", {
|
||||
id: "msg-2",
|
||||
type: "message",
|
||||
sender: "user",
|
||||
recipient: "team-lead",
|
||||
content: "World",
|
||||
timestamp: new Date().toISOString(),
|
||||
read: true,
|
||||
})
|
||||
|
||||
const tool = createReadInboxTool()
|
||||
|
||||
//#when
|
||||
const resultStr = await tool.execute({
|
||||
team_name: teamName,
|
||||
agent_name: "team-lead",
|
||||
}, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].id).toBe("msg-1")
|
||||
expect(result[1].id).toBe("msg-2")
|
||||
})
|
||||
|
||||
test("returns only unread messages when unread_only is true", async () => {
|
||||
//#given
|
||||
ensureInbox(teamName, "team-lead")
|
||||
appendInboxMessage(teamName, "team-lead", {
|
||||
id: "msg-1",
|
||||
type: "message",
|
||||
sender: "user",
|
||||
recipient: "team-lead",
|
||||
content: "Hello",
|
||||
timestamp: new Date().toISOString(),
|
||||
read: false,
|
||||
})
|
||||
appendInboxMessage(teamName, "team-lead", {
|
||||
id: "msg-2",
|
||||
type: "message",
|
||||
sender: "user",
|
||||
recipient: "team-lead",
|
||||
content: "World",
|
||||
timestamp: new Date().toISOString(),
|
||||
read: true,
|
||||
})
|
||||
|
||||
const tool = createReadInboxTool()
|
||||
|
||||
//#when
|
||||
const resultStr = await tool.execute({
|
||||
team_name: teamName,
|
||||
agent_name: "team-lead",
|
||||
unread_only: true,
|
||||
}, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("msg-1")
|
||||
})
|
||||
|
||||
test("marks messages as read when mark_as_read is true", async () => {
|
||||
//#given
|
||||
ensureInbox(teamName, "team-lead")
|
||||
appendInboxMessage(teamName, "team-lead", {
|
||||
id: "msg-1",
|
||||
type: "message",
|
||||
sender: "user",
|
||||
recipient: "team-lead",
|
||||
content: "Hello",
|
||||
timestamp: new Date().toISOString(),
|
||||
read: false,
|
||||
})
|
||||
|
||||
const tool = createReadInboxTool()
|
||||
|
||||
//#when
|
||||
await tool.execute({
|
||||
team_name: teamName,
|
||||
agent_name: "team-lead",
|
||||
mark_as_read: true,
|
||||
}, TEST_CONTEXT)
|
||||
|
||||
// Read again to check if marked as read
|
||||
const resultStr = await tool.execute({
|
||||
team_name: teamName,
|
||||
agent_name: "team-lead",
|
||||
unread_only: true,
|
||||
}, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveLength(0) // Should be marked as read
|
||||
})
|
||||
|
||||
test("returns empty array for non-existent inbox", async () => {
|
||||
//#given
|
||||
const tool = createReadInboxTool()
|
||||
|
||||
//#when
|
||||
const resultStr = await tool.execute({
|
||||
team_name: "nonexistent",
|
||||
agent_name: "team-lead",
|
||||
}, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
test("requires team_name and agent_name parameters", async () => {
|
||||
//#given
|
||||
const tool = createReadInboxTool()
|
||||
|
||||
//#when
|
||||
const resultStr = await tool.execute({}, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveProperty("error")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,29 +0,0 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||
import { readInbox } from "./inbox-store"
|
||||
import { ReadInboxInputSchema } from "./types"
|
||||
|
||||
export function createReadInboxTool(): ToolDefinition {
|
||||
return tool({
|
||||
description: "Read inbox messages for a team member.",
|
||||
args: {
|
||||
team_name: tool.schema.string().describe("Team name"),
|
||||
agent_name: tool.schema.string().describe("Member name"),
|
||||
unread_only: tool.schema.boolean().optional().describe("Return only unread messages"),
|
||||
mark_as_read: tool.schema.boolean().optional().describe("Mark returned messages as read"),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>): Promise<string> => {
|
||||
try {
|
||||
const input = ReadInboxInputSchema.parse(args)
|
||||
const messages = readInbox(
|
||||
input.team_name,
|
||||
input.agent_name,
|
||||
input.unread_only ?? false,
|
||||
input.mark_as_read ?? false,
|
||||
)
|
||||
return JSON.stringify(messages)
|
||||
} catch (error) {
|
||||
return JSON.stringify({ error: error instanceof Error ? error.message : "read_inbox_failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { createAgentTeamsTools } from "./tools"
|
||||
@@ -1,467 +0,0 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { mkdtempSync, rmSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import { readInbox } from "./inbox-store"
|
||||
import { createAgentTeamsTools } from "./tools"
|
||||
import { readTeamConfig, upsertTeammate, writeTeamConfig } from "./team-config-store"
|
||||
|
||||
interface TestToolContext {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
agent: string
|
||||
abort: AbortSignal
|
||||
}
|
||||
|
||||
interface ResumeCall {
|
||||
sessionId: string
|
||||
prompt: string
|
||||
}
|
||||
|
||||
function createContext(sessionID = "ses-main"): TestToolContext {
|
||||
return {
|
||||
sessionID,
|
||||
messageID: "msg-main",
|
||||
agent: "sisyphus",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
}
|
||||
|
||||
async function executeJsonTool(
|
||||
tools: ReturnType<typeof createAgentTeamsTools>,
|
||||
toolName: keyof ReturnType<typeof createAgentTeamsTools>,
|
||||
args: Record<string, unknown>,
|
||||
context: TestToolContext,
|
||||
): Promise<unknown> {
|
||||
const output = await tools[toolName].execute(args, context)
|
||||
return JSON.parse(output)
|
||||
}
|
||||
|
||||
function uniqueTeam(): string {
|
||||
return `msg-${randomUUID().slice(0, 8)}`
|
||||
}
|
||||
|
||||
function createMockManager(): { manager: BackgroundManager; resumeCalls: ResumeCall[] } {
|
||||
const resumeCalls: ResumeCall[] = []
|
||||
let launchCount = 0
|
||||
|
||||
const manager = {
|
||||
launch: async () => {
|
||||
launchCount += 1
|
||||
return { id: `bg-${launchCount}`, sessionID: `ses-worker-${launchCount}` }
|
||||
},
|
||||
getTask: () => undefined,
|
||||
resume: async (args: ResumeCall) => {
|
||||
resumeCalls.push(args)
|
||||
return { id: `resume-${resumeCalls.length}` }
|
||||
},
|
||||
} as unknown as BackgroundManager
|
||||
|
||||
return { manager, resumeCalls }
|
||||
}
|
||||
|
||||
async function setupTeamWithWorker(
|
||||
_tools: ReturnType<typeof createAgentTeamsTools>,
|
||||
context: TestToolContext,
|
||||
teamName = "core",
|
||||
workerName = "worker_1",
|
||||
): Promise<void> {
|
||||
await executeJsonTool(_tools, "team_create", { team_name: teamName }, context)
|
||||
|
||||
const config = readTeamConfig(teamName)
|
||||
if (config) {
|
||||
const teammate = {
|
||||
agentId: `agent-${randomUUID()}`,
|
||||
name: workerName,
|
||||
agentType: "teammate" as const,
|
||||
category: "quick",
|
||||
model: "default",
|
||||
prompt: "Handle tasks",
|
||||
joinedAt: new Date().toISOString(),
|
||||
color: "#FF5733",
|
||||
cwd: process.cwd(),
|
||||
planModeRequired: false,
|
||||
subscriptions: [],
|
||||
backendType: "native" as const,
|
||||
isActive: true,
|
||||
}
|
||||
const updatedConfig = upsertTeammate(config, teammate)
|
||||
writeTeamConfig(teamName, updatedConfig)
|
||||
}
|
||||
}
|
||||
|
||||
async function addTeammateManually(teamName: string, workerName: string): Promise<void> {
|
||||
const config = readTeamConfig(teamName)
|
||||
if (config) {
|
||||
const teammate = {
|
||||
agentId: `agent-${randomUUID()}`,
|
||||
name: workerName,
|
||||
agentType: "teammate" as const,
|
||||
category: "quick",
|
||||
model: "default",
|
||||
prompt: "Handle tasks",
|
||||
joinedAt: new Date().toISOString(),
|
||||
color: "#FF5733",
|
||||
cwd: process.cwd(),
|
||||
planModeRequired: false,
|
||||
subscriptions: [],
|
||||
backendType: "native" as const,
|
||||
isActive: true,
|
||||
}
|
||||
const updatedConfig = upsertTeammate(config, teammate)
|
||||
writeTeamConfig(teamName, updatedConfig)
|
||||
}
|
||||
}
|
||||
|
||||
describe("agent-teams messaging tools", () => {
|
||||
let originalCwd: string
|
||||
let tempProjectDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd()
|
||||
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-messaging-"))
|
||||
process.chdir(tempProjectDir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd)
|
||||
rmSync(tempProjectDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("message type", () => {
|
||||
test("delivers message to recipient inbox", async () => {
|
||||
//#given
|
||||
const tn = uniqueTeam()
|
||||
const { manager } = createMockManager()
|
||||
const tools = createAgentTeamsTools(manager)
|
||||
const leadContext = createContext()
|
||||
await setupTeamWithWorker(tools, leadContext, tn)
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(
|
||||
tools,
|
||||
"send_message",
|
||||
{
|
||||
team_name: tn,
|
||||
type: "message",
|
||||
recipient: "worker_1",
|
||||
content: "Please update status.",
|
||||
summary: "status_request",
|
||||
},
|
||||
leadContext,
|
||||
) as { success?: boolean; message?: string }
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.message).toBe("message_sent:worker_1")
|
||||
const inbox = readInbox(tn, "worker_1")
|
||||
const delivered = inbox.filter((m) => m.summary === "status_request")
|
||||
expect(delivered.length).toBeGreaterThanOrEqual(1)
|
||||
expect(delivered[0]?.sender).toBe("team-lead")
|
||||
expect(delivered[0]?.content).toBe("Please update status.")
|
||||
})
|
||||
|
||||
test("rejects message to nonexistent recipient", async () => {
|
||||
//#given
|
||||
const tn = uniqueTeam()
|
||||
const { manager } = createMockManager()
|
||||
const tools = createAgentTeamsTools(manager)
|
||||
const leadContext = createContext()
|
||||
await setupTeamWithWorker(tools, leadContext, tn)
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(
|
||||
tools,
|
||||
"send_message",
|
||||
{ team_name: tn, type: "message", recipient: "nonexistent", content: "hello", summary: "test" },
|
||||
leadContext,
|
||||
) as { error?: string }
|
||||
|
||||
//#then
|
||||
expect(result.error).toBe("message_recipient_not_found")
|
||||
})
|
||||
|
||||
test("rejects recipient with team suffix mismatch", async () => {
|
||||
//#given
|
||||
const tn = uniqueTeam()
|
||||
const { manager, resumeCalls } = createMockManager()
|
||||
const tools = createAgentTeamsTools(manager)
|
||||
const leadContext = createContext()
|
||||
await setupTeamWithWorker(tools, leadContext, tn)
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(
|
||||
tools,
|
||||
"send_message",
|
||||
{ team_name: tn, type: "message", recipient: "worker_1@other-team", summary: "sync", content: "hi" },
|
||||
leadContext,
|
||||
) as { error?: string }
|
||||
|
||||
//#then
|
||||
expect(result.error).toBe("recipient_team_mismatch")
|
||||
expect(resumeCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("rejects recipient with empty team suffix", async () => {
|
||||
//#given
|
||||
const tn = uniqueTeam()
|
||||
const { manager, resumeCalls } = createMockManager()
|
||||
const tools = createAgentTeamsTools(manager)
|
||||
const leadContext = createContext()
|
||||
await setupTeamWithWorker(tools, leadContext, tn)
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(
|
||||
tools,
|
||||
"send_message",
|
||||
{ team_name: tn, type: "message", recipient: "worker_1@", summary: "sync", content: "hi" },
|
||||
leadContext,
|
||||
) as { error?: string }
|
||||
|
||||
//#then
|
||||
expect(result.error).toBe("recipient_team_invalid")
|
||||
expect(resumeCalls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("broadcast type", () => {
|
||||
test("writes to all teammate inboxes", async () => {
|
||||
//#given
|
||||
const tn = uniqueTeam()
|
||||
const { manager } = createMockManager()
|
||||
const tools = createAgentTeamsTools(manager)
|
||||
const leadContext = createContext()
|
||||
await executeJsonTool(tools, "team_create", { team_name: tn }, leadContext)
|
||||
for (const name of ["worker_1", "worker_2"]) {
|
||||
await addTeammateManually(tn, name)
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(
|
||||
tools,
|
||||
"send_message",
|
||||
{ team_name: tn, type: "broadcast", summary: "sync", content: "Status update needed" },
|
||||
leadContext,
|
||||
) as { success?: boolean; message?: string }
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.message).toBe("broadcast_sent:2")
|
||||
for (const name of ["worker_1", "worker_2"]) {
|
||||
const inbox = readInbox(tn, name)
|
||||
const broadcastMessages = inbox.filter((m) => m.summary === "sync")
|
||||
expect(broadcastMessages.length).toBeGreaterThanOrEqual(1)
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects broadcast without summary", async () => {
|
||||
//#given
|
||||
const tn = uniqueTeam()
|
||||
const { manager } = createMockManager()
|
||||
const tools = createAgentTeamsTools(manager)
|
||||
const leadContext = createContext()
|
||||
await setupTeamWithWorker(tools, leadContext, tn)
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(
|
||||
tools,
|
||||
"send_message",
|
||||
{ team_name: tn, type: "broadcast", content: "hello" },
|
||||
leadContext,
|
||||
) as { error?: string }
|
||||
|
||||
//#then
|
||||
expect(result.error).toBe("broadcast_requires_summary")
|
||||
})
|
||||
})
|
||||
|
||||
describe("shutdown_request type", () => {
|
||||
test("sends shutdown request and returns request_id", async () => {
|
||||
//#given
|
||||
const tn = uniqueTeam()
|
||||
const { manager } = createMockManager()
|
||||
const tools = createAgentTeamsTools(manager)
|
||||
const leadContext = createContext()
|
||||
await setupTeamWithWorker(tools, leadContext, tn)
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(
|
||||
tools,
|
||||
"send_message",
|
||||
{ team_name: tn, type: "shutdown_request", recipient: "worker_1", content: "Work completed" },
|
||||
leadContext,
|
||||
) as { success?: boolean; request_id?: string; target?: string }
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.request_id).toMatch(/^shutdown-worker_1-/)
|
||||
expect(result.target).toBe("worker_1")
|
||||
})
|
||||
|
||||
test("rejects shutdown targeting team-lead", async () => {
|
||||
//#given
|
||||
const tn = uniqueTeam()
|
||||
const { manager } = createMockManager()
|
||||
const tools = createAgentTeamsTools(manager)
|
||||
const leadContext = createContext()
|
||||
await setupTeamWithWorker(tools, leadContext, tn)
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(
|
||||
tools,
|
||||
"send_message",
|
||||
{ team_name: tn, type: "shutdown_request", recipient: "team-lead" },
|
||||
leadContext,
|
||||
) as { error?: string }
|
||||
|
||||
//#then
|
||||
expect(result.error).toBe("cannot_shutdown_team_lead")
|
||||
})
|
||||
})
|
||||
|
||||
describe("shutdown_response type", () => {
|
||||
test("sends approved shutdown response to team-lead inbox", async () => {
|
||||
//#given
|
||||
const tn = uniqueTeam()
|
||||
const { manager } = createMockManager()
|
||||
const tools = createAgentTeamsTools(manager)
|
||||
const leadContext = createContext()
|
||||
await setupTeamWithWorker(tools, leadContext, tn)
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(
|
||||
tools,
|
||||
"send_message",
|
||||
{ team_name: tn, type: "shutdown_response", request_id: "shutdown-worker_1-abc12345", approve: true },
|
||||
leadContext,
|
||||
) as { success?: boolean; message?: string }
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.message).toBe("shutdown_approved:shutdown-worker_1-abc12345")
|
||||
const leadInbox = readInbox(tn, "team-lead")
|
||||
const shutdownMessages = leadInbox.filter((m) => m.summary === "shutdown_approved")
|
||||
expect(shutdownMessages.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
test("sends rejected shutdown response", async () => {
|
||||
//#given
|
||||
const tn = uniqueTeam()
|
||||
const { manager } = createMockManager()
|
||||
const tools = createAgentTeamsTools(manager)
|
||||
const leadContext = createContext()
|
||||
await setupTeamWithWorker(tools, leadContext, tn)
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(
|
||||
tools,
|
||||
"send_message",
|
||||
{
|
||||
team_name: tn,
|
||||
type: "shutdown_response",
|
||||
request_id: "shutdown-worker_1-abc12345",
|
||||
approve: false,
|
||||
content: "Still working on it",
|
||||
},
|
||||
leadContext,
|
||||
) as { success?: boolean; message?: string }
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.message).toBe("shutdown_rejected:shutdown-worker_1-abc12345")
|
||||
})
|
||||
})
|
||||
|
||||
describe("plan_approval_response type", () => {
|
||||
test("sends approved plan response", async () => {
|
||||
//#given
|
||||
const tn = uniqueTeam()
|
||||
const { manager } = createMockManager()
|
||||
const tools = createAgentTeamsTools(manager)
|
||||
const leadContext = createContext()
|
||||
await setupTeamWithWorker(tools, leadContext, tn)
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(
|
||||
tools,
|
||||
"send_message",
|
||||
{ team_name: tn, type: "plan_approval_response", request_id: "plan-req-001", approve: true, recipient: "worker_1" },
|
||||
leadContext,
|
||||
) as { success?: boolean; message?: string }
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.message).toBe("plan_approved:worker_1")
|
||||
})
|
||||
|
||||
test("sends rejected plan response with content", async () => {
|
||||
//#given
|
||||
const tn = uniqueTeam()
|
||||
const { manager } = createMockManager()
|
||||
const tools = createAgentTeamsTools(manager)
|
||||
const leadContext = createContext()
|
||||
await setupTeamWithWorker(tools, leadContext, tn)
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(
|
||||
tools,
|
||||
"send_message",
|
||||
{
|
||||
team_name: tn,
|
||||
type: "plan_approval_response",
|
||||
request_id: "plan-req-002",
|
||||
approve: false,
|
||||
recipient: "worker_1",
|
||||
content: "Need more details",
|
||||
},
|
||||
leadContext,
|
||||
) as { success?: boolean; message?: string }
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.message).toBe("plan_rejected:worker_1")
|
||||
})
|
||||
})
|
||||
|
||||
describe("authorization", () => {
|
||||
test("rejects message from unauthorized session", async () => {
|
||||
//#given
|
||||
const tn = uniqueTeam()
|
||||
const { manager } = createMockManager()
|
||||
const tools = createAgentTeamsTools(manager)
|
||||
const leadContext = createContext()
|
||||
await setupTeamWithWorker(tools, leadContext, tn)
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(
|
||||
tools,
|
||||
"send_message",
|
||||
{ team_name: tn, type: "message", recipient: "worker_1", content: "hello", summary: "test" },
|
||||
createContext("ses-intruder"),
|
||||
) as { error?: string }
|
||||
|
||||
//#then
|
||||
expect(result.error).toBe("unauthorized_sender_session")
|
||||
})
|
||||
|
||||
test("rejects message to nonexistent team", async () => {
|
||||
//#given
|
||||
const { manager } = createMockManager()
|
||||
const tools = createAgentTeamsTools(manager)
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(
|
||||
tools,
|
||||
"send_message",
|
||||
{ team_name: "nonexistent-xyz", type: "message", recipient: "w", content: "hello", summary: "test" },
|
||||
createContext(),
|
||||
) as { error?: string }
|
||||
|
||||
//#then
|
||||
expect(result.error).toBe("team_not_found")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,282 +0,0 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import { buildShutdownRequestId, readInbox, sendPlainInboxMessage, sendStructuredInboxMessage } from "./inbox-store"
|
||||
import { getTeamMember, listTeammates, readTeamConfigOrThrow } from "./team-config-store"
|
||||
import { validateAgentNameOrLead, validateTeamName } from "./name-validation"
|
||||
import { resumeTeammateWithMessage } from "./teammate-runtime"
|
||||
import {
|
||||
TeamConfig,
|
||||
TeamReadInboxInputSchema,
|
||||
TeamSendMessageInputSchema,
|
||||
TeamToolContext,
|
||||
isTeammateMember,
|
||||
} from "./types"
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
|
||||
function validateRecipientTeam(recipient: unknown, teamName: string): string | null {
|
||||
if (typeof recipient !== "string") {
|
||||
return null
|
||||
}
|
||||
|
||||
const trimmed = recipient.trim()
|
||||
const atIndex = trimmed.indexOf("@")
|
||||
if (atIndex <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const specifiedTeam = trimmed.slice(atIndex + 1).trim()
|
||||
if (!specifiedTeam) {
|
||||
return "recipient_team_invalid"
|
||||
}
|
||||
if (specifiedTeam === teamName) {
|
||||
return null
|
||||
}
|
||||
|
||||
return "recipient_team_mismatch"
|
||||
}
|
||||
|
||||
function resolveSenderFromContext(config: TeamConfig, context: TeamToolContext): string | null {
|
||||
if (context.sessionID === config.leadSessionId) {
|
||||
return "team-lead"
|
||||
}
|
||||
|
||||
const matchedMember = config.members.find((member) => isTeammateMember(member) && member.sessionID === context.sessionID)
|
||||
return matchedMember?.name ?? null
|
||||
}
|
||||
|
||||
export function createSendMessageTool(manager: BackgroundManager): ToolDefinition {
|
||||
return tool({
|
||||
description: "Send direct or broadcast team messages and protocol responses.",
|
||||
args: {
|
||||
team_name: tool.schema.string().describe("Team name"),
|
||||
type: tool.schema.enum(["message", "broadcast", "shutdown_request", "shutdown_response", "plan_approval_response"]),
|
||||
recipient: tool.schema.string().optional().describe("Message recipient"),
|
||||
content: tool.schema.string().optional().describe("Message body"),
|
||||
summary: tool.schema.string().optional().describe("Short summary"),
|
||||
request_id: tool.schema.string().optional().describe("Protocol request id"),
|
||||
approve: tool.schema.boolean().optional().describe("Approval flag"),
|
||||
sender: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Sender name inferred from calling session; explicit value must match resolved sender."),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
|
||||
try {
|
||||
const input = TeamSendMessageInputSchema.parse(args)
|
||||
const teamError = validateTeamName(input.team_name)
|
||||
if (teamError) {
|
||||
return JSON.stringify({ error: teamError })
|
||||
}
|
||||
const recipientTeamError = validateRecipientTeam(args.recipient, input.team_name)
|
||||
if (recipientTeamError) {
|
||||
return JSON.stringify({ error: recipientTeamError })
|
||||
}
|
||||
const requestedSender = input.sender
|
||||
const senderError = requestedSender ? validateAgentNameOrLead(requestedSender) : null
|
||||
if (senderError) {
|
||||
return JSON.stringify({ error: senderError })
|
||||
}
|
||||
const config = readTeamConfigOrThrow(input.team_name)
|
||||
const actor = resolveSenderFromContext(config, context)
|
||||
if (!actor) {
|
||||
return JSON.stringify({ error: "unauthorized_sender_session" })
|
||||
}
|
||||
if (requestedSender && requestedSender !== actor) {
|
||||
return JSON.stringify({ error: "sender_context_mismatch" })
|
||||
}
|
||||
const sender = requestedSender ?? actor
|
||||
|
||||
const memberNames = new Set(config.members.map((member) => member.name))
|
||||
if (sender !== "team-lead" && !memberNames.has(sender)) {
|
||||
return JSON.stringify({ error: "invalid_sender" })
|
||||
}
|
||||
|
||||
if (input.type === "message") {
|
||||
if (!input.recipient || !input.summary || !input.content) {
|
||||
return JSON.stringify({ error: "message_requires_recipient_summary_content" })
|
||||
}
|
||||
if (!memberNames.has(input.recipient)) {
|
||||
return JSON.stringify({ error: "message_recipient_not_found" })
|
||||
}
|
||||
|
||||
const targetMember = getTeamMember(config, input.recipient)
|
||||
const color = targetMember && isTeammateMember(targetMember) ? targetMember.color : undefined
|
||||
sendPlainInboxMessage(input.team_name, sender, input.recipient, input.content, input.summary, color)
|
||||
|
||||
if (targetMember && isTeammateMember(targetMember)) {
|
||||
await resumeTeammateWithMessage(manager, context, input.team_name, targetMember, input.summary, input.content)
|
||||
}
|
||||
|
||||
return JSON.stringify({ success: true, message: `message_sent:${input.recipient}` })
|
||||
}
|
||||
|
||||
if (input.type === "broadcast") {
|
||||
if (!input.summary) {
|
||||
return JSON.stringify({ error: "broadcast_requires_summary" })
|
||||
}
|
||||
const broadcastSummary = input.summary
|
||||
const teammates = listTeammates(config)
|
||||
for (const teammate of teammates) {
|
||||
sendPlainInboxMessage(input.team_name, sender, teammate.name, input.content ?? "", broadcastSummary)
|
||||
}
|
||||
await Promise.allSettled(
|
||||
teammates.map((teammate) =>
|
||||
resumeTeammateWithMessage(manager, context, input.team_name, teammate, broadcastSummary, input.content ?? ""),
|
||||
),
|
||||
)
|
||||
return JSON.stringify({ success: true, message: `broadcast_sent:${teammates.length}` })
|
||||
}
|
||||
|
||||
if (input.type === "shutdown_request") {
|
||||
if (!input.recipient) {
|
||||
return JSON.stringify({ error: "shutdown_request_requires_recipient" })
|
||||
}
|
||||
if (input.recipient === "team-lead") {
|
||||
return JSON.stringify({ error: "cannot_shutdown_team_lead" })
|
||||
}
|
||||
const targetMember = getTeamMember(config, input.recipient)
|
||||
if (!targetMember || !isTeammateMember(targetMember)) {
|
||||
return JSON.stringify({ error: "shutdown_recipient_not_found" })
|
||||
}
|
||||
|
||||
const requestId = buildShutdownRequestId(input.recipient)
|
||||
sendStructuredInboxMessage(
|
||||
input.team_name,
|
||||
sender,
|
||||
input.recipient,
|
||||
{
|
||||
type: "shutdown_request",
|
||||
requestId,
|
||||
from: sender,
|
||||
reason: input.content ?? "",
|
||||
timestamp: nowIso(),
|
||||
},
|
||||
"shutdown_request",
|
||||
)
|
||||
|
||||
await resumeTeammateWithMessage(
|
||||
manager,
|
||||
context,
|
||||
input.team_name,
|
||||
targetMember,
|
||||
"shutdown_request",
|
||||
input.content ?? "Shutdown requested",
|
||||
)
|
||||
|
||||
return JSON.stringify({ success: true, request_id: requestId, target: input.recipient })
|
||||
}
|
||||
|
||||
if (input.type === "shutdown_response") {
|
||||
if (!input.request_id) {
|
||||
return JSON.stringify({ error: "shutdown_response_requires_request_id" })
|
||||
}
|
||||
if (input.approve) {
|
||||
sendStructuredInboxMessage(
|
||||
input.team_name,
|
||||
sender,
|
||||
"team-lead",
|
||||
{
|
||||
type: "shutdown_approved",
|
||||
requestId: input.request_id,
|
||||
from: sender,
|
||||
timestamp: nowIso(),
|
||||
backendType: "native",
|
||||
},
|
||||
"shutdown_approved",
|
||||
)
|
||||
return JSON.stringify({ success: true, message: `shutdown_approved:${input.request_id}` })
|
||||
}
|
||||
|
||||
sendPlainInboxMessage(
|
||||
input.team_name,
|
||||
sender,
|
||||
"team-lead",
|
||||
input.content ?? "Shutdown rejected",
|
||||
"shutdown_rejected",
|
||||
)
|
||||
return JSON.stringify({ success: true, message: `shutdown_rejected:${input.request_id}` })
|
||||
}
|
||||
|
||||
if (!input.recipient) {
|
||||
return JSON.stringify({ error: "plan_response_requires_recipient" })
|
||||
}
|
||||
if (!memberNames.has(input.recipient)) {
|
||||
return JSON.stringify({ error: "plan_response_recipient_not_found" })
|
||||
}
|
||||
|
||||
if (input.approve) {
|
||||
sendStructuredInboxMessage(
|
||||
input.team_name,
|
||||
sender,
|
||||
input.recipient,
|
||||
{
|
||||
type: "plan_approval",
|
||||
approved: true,
|
||||
requestId: input.request_id,
|
||||
},
|
||||
"plan_approved",
|
||||
)
|
||||
return JSON.stringify({ success: true, message: `plan_approved:${input.recipient}` })
|
||||
}
|
||||
|
||||
sendPlainInboxMessage(
|
||||
input.team_name,
|
||||
sender,
|
||||
input.recipient,
|
||||
input.content ?? "Plan rejected",
|
||||
"plan_rejected",
|
||||
)
|
||||
return JSON.stringify({ success: true, message: `plan_rejected:${input.recipient}` })
|
||||
} catch (error) {
|
||||
return JSON.stringify({ error: error instanceof Error ? error.message : "send_message_failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createReadInboxTool(): ToolDefinition {
|
||||
return tool({
|
||||
description: "Read inbox messages for a team member.",
|
||||
args: {
|
||||
team_name: tool.schema.string().describe("Team name"),
|
||||
agent_name: tool.schema.string().describe("Member name"),
|
||||
unread_only: tool.schema.boolean().optional().describe("Return only unread messages"),
|
||||
mark_as_read: tool.schema.boolean().optional().describe("Mark returned messages as read"),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
|
||||
try {
|
||||
const input = TeamReadInboxInputSchema.parse(args)
|
||||
const teamError = validateTeamName(input.team_name)
|
||||
if (teamError) {
|
||||
return JSON.stringify({ error: teamError })
|
||||
}
|
||||
const agentError = validateAgentNameOrLead(input.agent_name)
|
||||
if (agentError) {
|
||||
return JSON.stringify({ error: agentError })
|
||||
}
|
||||
const config = readTeamConfigOrThrow(input.team_name)
|
||||
const actor = resolveSenderFromContext(config, context)
|
||||
if (!actor) {
|
||||
return JSON.stringify({ error: "unauthorized_reader_session" })
|
||||
}
|
||||
|
||||
if (actor !== "team-lead" && actor !== input.agent_name) {
|
||||
return JSON.stringify({ error: "unauthorized_reader_session" })
|
||||
}
|
||||
|
||||
const messages = readInbox(
|
||||
input.team_name,
|
||||
input.agent_name,
|
||||
input.unread_only ?? false,
|
||||
input.mark_as_read ?? true,
|
||||
)
|
||||
return JSON.stringify(messages)
|
||||
} catch (error) {
|
||||
return JSON.stringify({ error: error instanceof Error ? error.message : "read_inbox_failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
validateAgentName,
|
||||
validateAgentNameOrLead,
|
||||
validateTaskId,
|
||||
validateTeamName,
|
||||
} from "./name-validation"
|
||||
|
||||
describe("agent-teams name validation", () => {
|
||||
test("accepts valid team names", () => {
|
||||
//#given
|
||||
const validNames = ["team_1", "alpha-team", "A1"]
|
||||
|
||||
//#when
|
||||
const result = validNames.map(validateTeamName)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual([null, null, null])
|
||||
})
|
||||
|
||||
test("rejects invalid and empty team names", () => {
|
||||
//#given
|
||||
const blank = ""
|
||||
const invalid = "team space"
|
||||
const tooLong = "a".repeat(65)
|
||||
|
||||
//#when
|
||||
const blankResult = validateTeamName(blank)
|
||||
const invalidResult = validateTeamName(invalid)
|
||||
const tooLongResult = validateTeamName(tooLong)
|
||||
|
||||
//#then
|
||||
expect(blankResult).toBe("team_name_required")
|
||||
expect(invalidResult).toBe("team_name_invalid")
|
||||
expect(tooLongResult).toBe("team_name_too_long")
|
||||
})
|
||||
|
||||
test("rejects reserved teammate name", () => {
|
||||
//#given
|
||||
const reservedName = "team-lead"
|
||||
|
||||
//#when
|
||||
const result = validateAgentName(reservedName)
|
||||
|
||||
//#then
|
||||
expect(result).toBe("agent_name_reserved")
|
||||
})
|
||||
|
||||
test("validates regular agent names", () => {
|
||||
//#given
|
||||
const valid = "worker_1"
|
||||
const invalid = "worker one"
|
||||
|
||||
//#when
|
||||
const validResult = validateAgentName(valid)
|
||||
const invalidResult = validateAgentName(invalid)
|
||||
|
||||
//#then
|
||||
expect(validResult).toBeNull()
|
||||
expect(invalidResult).toBe("agent_name_invalid")
|
||||
})
|
||||
|
||||
test("allows team-lead for inbox-compatible validation", () => {
|
||||
//#then
|
||||
expect(validateAgentNameOrLead("team-lead")).toBeNull()
|
||||
expect(validateAgentNameOrLead("worker_1")).toBeNull()
|
||||
expect(validateAgentNameOrLead("worker one")).toBe("agent_name_invalid")
|
||||
})
|
||||
|
||||
test("validates task ids", () => {
|
||||
//#then
|
||||
expect(validateTaskId("T-123")).toBeNull()
|
||||
expect(validateTaskId("123")).toBe("task_id_invalid")
|
||||
expect(validateTaskId("")).toBe("task_id_required")
|
||||
expect(validateTaskId("../../etc/passwd")).toBe("task_id_invalid")
|
||||
expect(validateTaskId(`T-${"a".repeat(127)}`)).toBe("task_id_too_long")
|
||||
})
|
||||
})
|
||||
@@ -1,54 +0,0 @@
|
||||
const VALID_NAME_RE = /^[A-Za-z0-9_-]+$/
|
||||
const MAX_NAME_LENGTH = 64
|
||||
const VALID_TASK_ID_RE = /^T-[A-Za-z0-9_-]+$/
|
||||
const MAX_TASK_ID_LENGTH = 128
|
||||
|
||||
function validateName(value: string, label: "team" | "agent"): string | null {
|
||||
if (!value || !value.trim()) {
|
||||
return `${label}_name_required`
|
||||
}
|
||||
|
||||
if (!VALID_NAME_RE.test(value)) {
|
||||
return `${label}_name_invalid`
|
||||
}
|
||||
|
||||
if (value.length > MAX_NAME_LENGTH) {
|
||||
return `${label}_name_too_long`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function validateTeamName(teamName: string): string | null {
|
||||
return validateName(teamName, "team")
|
||||
}
|
||||
|
||||
export function validateAgentName(agentName: string): string | null {
|
||||
if (agentName === "team-lead") {
|
||||
return "agent_name_reserved"
|
||||
}
|
||||
return validateName(agentName, "agent")
|
||||
}
|
||||
|
||||
export function validateAgentNameOrLead(agentName: string): string | null {
|
||||
if (agentName === "team-lead") {
|
||||
return null
|
||||
}
|
||||
return validateName(agentName, "agent")
|
||||
}
|
||||
|
||||
export function validateTaskId(taskId: string): string | null {
|
||||
if (!taskId || !taskId.trim()) {
|
||||
return "task_id_required"
|
||||
}
|
||||
|
||||
if (!VALID_TASK_ID_RE.test(taskId)) {
|
||||
return "task_id_invalid"
|
||||
}
|
||||
|
||||
if (taskId.length > MAX_TASK_ID_LENGTH) {
|
||||
return "task_id_too_long"
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import {
|
||||
getAgentTeamsRootDir,
|
||||
getTeamConfigPath,
|
||||
getTeamDir,
|
||||
getTeamInboxDir,
|
||||
getTeamInboxPath,
|
||||
getTeamTaskDir,
|
||||
getTeamTaskPath,
|
||||
getTeamsRootDir,
|
||||
getTeamTasksRootDir,
|
||||
} from "./paths"
|
||||
|
||||
describe("agent-teams paths", () => {
|
||||
test("uses user-global .sisyphus directory as storage root", () => {
|
||||
//#given
|
||||
const expectedRoot = join(homedir(), ".sisyphus", "agent-teams")
|
||||
|
||||
//#when
|
||||
const root = getAgentTeamsRootDir()
|
||||
|
||||
//#then
|
||||
expect(root).toBe(expectedRoot)
|
||||
})
|
||||
|
||||
test("builds expected teams and tasks root directories", () => {
|
||||
//#given
|
||||
const expectedRoot = join(homedir(), ".sisyphus", "agent-teams")
|
||||
|
||||
//#when
|
||||
const teamsRoot = getTeamsRootDir()
|
||||
const tasksRoot = getTeamTasksRootDir()
|
||||
|
||||
//#then
|
||||
expect(teamsRoot).toBe(join(expectedRoot, "teams"))
|
||||
expect(tasksRoot).toBe(join(expectedRoot, "tasks"))
|
||||
})
|
||||
|
||||
test("builds team-scoped config, inbox, and task file paths", () => {
|
||||
//#given
|
||||
const teamName = "alpha_team"
|
||||
const agentName = "worker_1"
|
||||
const taskId = "T-123"
|
||||
const expectedTeamDir = join(getTeamsRootDir(), "alpha_team")
|
||||
|
||||
//#when
|
||||
const teamDir = getTeamDir(teamName)
|
||||
const configPath = getTeamConfigPath(teamName)
|
||||
const inboxDir = getTeamInboxDir(teamName)
|
||||
const inboxPath = getTeamInboxPath(teamName, agentName)
|
||||
const taskDir = getTeamTaskDir(teamName)
|
||||
const taskPath = getTeamTaskPath(teamName, taskId)
|
||||
|
||||
//#then
|
||||
expect(teamDir).toBe(expectedTeamDir)
|
||||
expect(configPath).toBe(join(expectedTeamDir, "config.json"))
|
||||
expect(inboxDir).toBe(join(expectedTeamDir, "inboxes"))
|
||||
expect(inboxPath).toBe(join(expectedTeamDir, "inboxes", `${agentName}.json`))
|
||||
expect(taskDir).toBe(join(getTeamTasksRootDir(), "alpha_team"))
|
||||
expect(taskPath).toBe(join(getTeamTasksRootDir(), "alpha_team", `${taskId}.json`))
|
||||
})
|
||||
|
||||
test("sanitizes team names with invalid characters", () => {
|
||||
//#given
|
||||
const invalidTeamName = "team space/with@special#chars"
|
||||
const expectedSanitized = "team-space-with-special-chars"
|
||||
|
||||
//#when
|
||||
const teamDir = getTeamDir(invalidTeamName)
|
||||
const configPath = getTeamConfigPath(invalidTeamName)
|
||||
const taskDir = getTeamTaskDir(invalidTeamName)
|
||||
|
||||
//#then
|
||||
expect(teamDir).toBe(join(getTeamsRootDir(), expectedSanitized))
|
||||
expect(configPath).toBe(join(getTeamsRootDir(), expectedSanitized, "config.json"))
|
||||
expect(taskDir).toBe(join(getTeamTasksRootDir(), expectedSanitized))
|
||||
})
|
||||
})
|
||||
@@ -1,42 +0,0 @@
|
||||
import { join } from "node:path"
|
||||
import { homedir } from "node:os"
|
||||
import { sanitizePathSegment } from "../../features/claude-tasks/storage"
|
||||
|
||||
const SISYPHUS_DIR = ".sisyphus"
|
||||
const AGENT_TEAMS_DIR = "agent-teams"
|
||||
|
||||
export function getAgentTeamsRootDir(): string {
|
||||
return join(homedir(), SISYPHUS_DIR, AGENT_TEAMS_DIR)
|
||||
}
|
||||
|
||||
export function getTeamsRootDir(): string {
|
||||
return join(getAgentTeamsRootDir(), "teams")
|
||||
}
|
||||
|
||||
export function getTeamTasksRootDir(): string {
|
||||
return join(getAgentTeamsRootDir(), "tasks")
|
||||
}
|
||||
|
||||
export function getTeamDir(teamName: string): string {
|
||||
return join(getTeamsRootDir(), sanitizePathSegment(teamName))
|
||||
}
|
||||
|
||||
export function getTeamConfigPath(teamName: string): string {
|
||||
return join(getTeamDir(teamName), "config.json")
|
||||
}
|
||||
|
||||
export function getTeamInboxDir(teamName: string): string {
|
||||
return join(getTeamDir(teamName), "inboxes")
|
||||
}
|
||||
|
||||
export function getTeamInboxPath(teamName: string, agentName: string): string {
|
||||
return join(getTeamInboxDir(teamName), `${agentName}.json`)
|
||||
}
|
||||
|
||||
export function getTeamTaskDir(teamName: string): string {
|
||||
return join(getTeamTasksRootDir(), sanitizePathSegment(teamName))
|
||||
}
|
||||
|
||||
export function getTeamTaskPath(teamName: string, taskId: string): string {
|
||||
return join(getTeamTaskDir(teamName), `${taskId}.json`)
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"
|
||||
import { chmodSync, existsSync, mkdtempSync, rmSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { acquireLock } from "../../features/claude-tasks/storage"
|
||||
import { getTeamDir, getTeamTaskDir, getTeamsRootDir } from "./paths"
|
||||
import {
|
||||
createTeamConfig,
|
||||
deleteTeamData,
|
||||
deleteTeamDir,
|
||||
listTeams,
|
||||
readTeamConfigOrThrow,
|
||||
teamExists,
|
||||
upsertTeammate,
|
||||
writeTeamConfig,
|
||||
} from "./team-config-store"
|
||||
|
||||
describe("agent-teams team config store", () => {
|
||||
let originalCwd: string
|
||||
let tempProjectDir: string
|
||||
let createdTeams: string[]
|
||||
let teamPrefix: string
|
||||
|
||||
beforeAll(() => {
|
||||
const allTeams = listTeams()
|
||||
for (const team of allTeams) {
|
||||
if (team.startsWith("core-") || team.startsWith("team-alpha-") || team.startsWith("team-beta-") || team.startsWith("delete-dir-test-")) {
|
||||
try {
|
||||
deleteTeamData(team)
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd()
|
||||
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-config-store-"))
|
||||
process.chdir(tempProjectDir)
|
||||
createdTeams = []
|
||||
teamPrefix = randomUUID().slice(0, 8)
|
||||
createTeamConfig(`core-${teamPrefix}`, "Core team", `ses-main-${teamPrefix}`, tempProjectDir, "sisyphus")
|
||||
createdTeams.push(`core-${teamPrefix}`)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
for (const teamName of createdTeams) {
|
||||
if (teamExists(teamName)) {
|
||||
try {
|
||||
deleteTeamData(teamName)
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
process.chdir(originalCwd)
|
||||
try {
|
||||
rmSync(tempProjectDir, { recursive: true, force: true })
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
})
|
||||
|
||||
test("deleteTeamData waits for team lock before removing team files", () => {
|
||||
//#given
|
||||
const teamName = createdTeams[0]
|
||||
const lock = acquireLock(getTeamDir(teamName))
|
||||
expect(lock.acquired).toBe(true)
|
||||
|
||||
try {
|
||||
//#when
|
||||
const deleteWhileLocked = () => deleteTeamData(teamName)
|
||||
|
||||
//#then
|
||||
expect(deleteWhileLocked).toThrow("team_lock_unavailable")
|
||||
expect(teamExists(teamName)).toBe(true)
|
||||
} finally {
|
||||
//#when
|
||||
lock.release()
|
||||
}
|
||||
|
||||
deleteTeamData(teamName)
|
||||
|
||||
//#then
|
||||
expect(teamExists(teamName)).toBe(false)
|
||||
})
|
||||
|
||||
test("deleteTeamData waits for task lock before removing task files", () => {
|
||||
//#given
|
||||
const teamName = createdTeams[0]
|
||||
const lock = acquireLock(getTeamTaskDir(teamName))
|
||||
expect(lock.acquired).toBe(true)
|
||||
|
||||
try {
|
||||
//#when
|
||||
const deleteWhileLocked = () => deleteTeamData(teamName)
|
||||
|
||||
//#then
|
||||
expect(deleteWhileLocked).toThrow("team_task_lock_unavailable")
|
||||
expect(teamExists(teamName)).toBe(true)
|
||||
} finally {
|
||||
lock.release()
|
||||
}
|
||||
|
||||
//#when
|
||||
deleteTeamData(teamName)
|
||||
|
||||
//#then
|
||||
expect(teamExists(teamName)).toBe(false)
|
||||
})
|
||||
|
||||
test("deleteTeamData removes task files before deleting team directory", () => {
|
||||
//#given
|
||||
const teamName = createdTeams[0]
|
||||
const taskDir = getTeamTaskDir(teamName)
|
||||
const teamDir = getTeamDir(teamName)
|
||||
const teamsRootDir = getTeamsRootDir()
|
||||
expect(existsSync(taskDir)).toBe(true)
|
||||
expect(existsSync(teamDir)).toBe(true)
|
||||
|
||||
//#when
|
||||
chmodSync(teamsRootDir, 0o555)
|
||||
try {
|
||||
const deleteWithBlockedTeamParent = () => deleteTeamData(teamName)
|
||||
expect(deleteWithBlockedTeamParent).toThrow()
|
||||
} finally {
|
||||
chmodSync(teamsRootDir, 0o755)
|
||||
}
|
||||
|
||||
//#then
|
||||
expect(existsSync(taskDir)).toBe(false)
|
||||
expect(existsSync(teamDir)).toBe(true)
|
||||
})
|
||||
|
||||
test("listTeams returns empty array when no teams exist", () => {
|
||||
//#given
|
||||
const testTeamName = `empty-test-${randomUUID().slice(0, 8)}`
|
||||
const allTeamsBefore = listTeams().filter(t => !t.startsWith("core-") && !t.startsWith("team-alpha-") && !t.startsWith("team-beta-") && !t.startsWith("delete-dir-test-"))
|
||||
const uniqueTestTeam = allTeamsBefore.find(t => t !== testTeamName)
|
||||
|
||||
//#when
|
||||
const teams = listTeams()
|
||||
|
||||
//#then
|
||||
expect(teams.length).toBeGreaterThanOrEqual(allTeamsBefore.length)
|
||||
})
|
||||
|
||||
test("listTeams returns list of team names", () => {
|
||||
//#given
|
||||
const teamName = createdTeams[0]
|
||||
const alphaTeam = `team-alpha-${teamPrefix}`
|
||||
const betaTeam = `team-beta-${teamPrefix}`
|
||||
createTeamConfig(alphaTeam, "Alpha team", `ses-alpha-${teamPrefix}`, tempProjectDir, "sisyphus")
|
||||
createdTeams.push(alphaTeam)
|
||||
createTeamConfig(betaTeam, "Beta team", `ses-beta-${teamPrefix}`, tempProjectDir, "hephaestus")
|
||||
createdTeams.push(betaTeam)
|
||||
|
||||
//#when
|
||||
const teams = listTeams()
|
||||
|
||||
//#then
|
||||
expect(teams).toContain(teamName)
|
||||
expect(teams).toContain(alphaTeam)
|
||||
expect(teams).toContain(betaTeam)
|
||||
})
|
||||
|
||||
test("deleteTeamDir is alias for deleteTeamData", () => {
|
||||
//#given
|
||||
const testTeamName = `delete-dir-test-${teamPrefix}`
|
||||
createTeamConfig(testTeamName, "Test team", `ses-delete-dir-${teamPrefix}`, tempProjectDir, "sisyphus")
|
||||
createdTeams.push(testTeamName)
|
||||
expect(teamExists(testTeamName)).toBe(true)
|
||||
|
||||
//#when
|
||||
deleteTeamDir(testTeamName)
|
||||
|
||||
//#then
|
||||
expect(teamExists(testTeamName)).toBe(false)
|
||||
})
|
||||
|
||||
test("deleteTeamData fails if team has active teammates", () => {
|
||||
//#given
|
||||
const teamName = createdTeams[0]
|
||||
const config = readTeamConfigOrThrow(teamName)
|
||||
const updated = upsertTeammate(config, {
|
||||
agentId: `teammate@${teamName}`,
|
||||
name: "teammate",
|
||||
agentType: "teammate",
|
||||
category: "test",
|
||||
model: "sisyphus",
|
||||
prompt: "test prompt",
|
||||
color: "#000000",
|
||||
planModeRequired: false,
|
||||
joinedAt: new Date().toISOString(),
|
||||
cwd: process.cwd(),
|
||||
subscriptions: [],
|
||||
backendType: "native",
|
||||
isActive: true,
|
||||
sessionID: "ses-sub",
|
||||
})
|
||||
writeTeamConfig(teamName, updated)
|
||||
|
||||
//#when
|
||||
const deleteWithTeammates = () => deleteTeamData(teamName)
|
||||
|
||||
//#then
|
||||
expect(deleteWithTeammates).toThrow("team_has_active_members")
|
||||
expect(teamExists(teamName)).toBe(true)
|
||||
})
|
||||
|
||||
})
|
||||
@@ -1,217 +0,0 @@
|
||||
import { existsSync, readdirSync, rmSync } from "node:fs"
|
||||
import { acquireLock, ensureDir, readJsonSafe, writeJsonAtomic } from "../../features/claude-tasks/storage"
|
||||
import {
|
||||
getTeamConfigPath,
|
||||
getTeamDir,
|
||||
getTeamInboxDir,
|
||||
getTeamTaskDir,
|
||||
getTeamTasksRootDir,
|
||||
getTeamsRootDir,
|
||||
} from "./paths"
|
||||
import {
|
||||
TEAM_COLOR_PALETTE,
|
||||
TeamConfig,
|
||||
TeamConfigSchema,
|
||||
TeamLeadMember,
|
||||
TeamMember,
|
||||
TeamTeammateMember,
|
||||
isTeammateMember,
|
||||
} from "./types"
|
||||
import { validateTeamName } from "./name-validation"
|
||||
import { withTeamTaskLock } from "./team-task-store"
|
||||
|
||||
function nowMs(): string {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
|
||||
function assertValidTeamName(teamName: string): void {
|
||||
const validationError = validateTeamName(teamName)
|
||||
if (validationError) {
|
||||
throw new Error(validationError)
|
||||
}
|
||||
}
|
||||
|
||||
function withTeamLock<T>(teamName: string, operation: () => T): T {
|
||||
assertValidTeamName(teamName)
|
||||
const teamDir = getTeamDir(teamName)
|
||||
ensureDir(teamDir)
|
||||
const lock = acquireLock(teamDir)
|
||||
if (!lock.acquired) {
|
||||
throw new Error("team_lock_unavailable")
|
||||
}
|
||||
|
||||
try {
|
||||
return operation()
|
||||
} finally {
|
||||
lock.release()
|
||||
}
|
||||
}
|
||||
|
||||
function createLeadMember(teamName: string, cwd: string, leadModel: string): TeamLeadMember {
|
||||
return {
|
||||
agentId: `team-lead@${teamName}`,
|
||||
name: "team-lead",
|
||||
agentType: "team-lead",
|
||||
color: "#2D3748",
|
||||
model: leadModel,
|
||||
joinedAt: nowMs(),
|
||||
cwd,
|
||||
subscriptions: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureTeamStorageDirs(teamName: string): void {
|
||||
assertValidTeamName(teamName)
|
||||
ensureDir(getTeamsRootDir())
|
||||
ensureDir(getTeamTasksRootDir())
|
||||
ensureDir(getTeamDir(teamName))
|
||||
ensureDir(getTeamInboxDir(teamName))
|
||||
ensureDir(getTeamTaskDir(teamName))
|
||||
}
|
||||
|
||||
export function teamExists(teamName: string): boolean {
|
||||
assertValidTeamName(teamName)
|
||||
return existsSync(getTeamConfigPath(teamName))
|
||||
}
|
||||
|
||||
export function createTeamConfig(
|
||||
teamName: string,
|
||||
description: string,
|
||||
leadSessionId: string,
|
||||
cwd: string,
|
||||
leadModel: string,
|
||||
): TeamConfig {
|
||||
ensureTeamStorageDirs(teamName)
|
||||
|
||||
const leadAgentId = `team-lead@${teamName}`
|
||||
const config: TeamConfig = {
|
||||
name: teamName,
|
||||
description,
|
||||
createdAt: nowMs(),
|
||||
leadAgentId,
|
||||
leadSessionId,
|
||||
members: [createLeadMember(teamName, cwd, leadModel)],
|
||||
}
|
||||
|
||||
return withTeamLock(teamName, () => {
|
||||
if (teamExists(teamName)) {
|
||||
throw new Error("team_already_exists")
|
||||
}
|
||||
writeJsonAtomic(getTeamConfigPath(teamName), TeamConfigSchema.parse(config))
|
||||
return config
|
||||
})
|
||||
}
|
||||
|
||||
export function readTeamConfig(teamName: string): TeamConfig | null {
|
||||
assertValidTeamName(teamName)
|
||||
return readJsonSafe(getTeamConfigPath(teamName), TeamConfigSchema)
|
||||
}
|
||||
|
||||
export function readTeamConfigOrThrow(teamName: string): TeamConfig {
|
||||
const config = readTeamConfig(teamName)
|
||||
if (!config) {
|
||||
throw new Error("team_not_found")
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
export function writeTeamConfig(teamName: string, config: TeamConfig): TeamConfig {
|
||||
assertValidTeamName(teamName)
|
||||
return withTeamLock(teamName, () => {
|
||||
const validated = TeamConfigSchema.parse(config)
|
||||
writeJsonAtomic(getTeamConfigPath(teamName), validated)
|
||||
return validated
|
||||
})
|
||||
}
|
||||
|
||||
export function updateTeamConfig(teamName: string, updater: (config: TeamConfig) => TeamConfig): TeamConfig {
|
||||
assertValidTeamName(teamName)
|
||||
return withTeamLock(teamName, () => {
|
||||
const current = readJsonSafe(getTeamConfigPath(teamName), TeamConfigSchema)
|
||||
if (!current) {
|
||||
throw new Error("team_not_found")
|
||||
}
|
||||
|
||||
const next = TeamConfigSchema.parse(updater(current))
|
||||
writeJsonAtomic(getTeamConfigPath(teamName), next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export function listTeammates(config: TeamConfig): TeamTeammateMember[] {
|
||||
return config.members.filter(isTeammateMember)
|
||||
}
|
||||
|
||||
export function getTeamMember(config: TeamConfig, name: string): TeamMember | undefined {
|
||||
return config.members.find((member) => member.name === name)
|
||||
}
|
||||
|
||||
export function upsertTeammate(config: TeamConfig, teammate: TeamTeammateMember): TeamConfig {
|
||||
const members = config.members.filter((member) => member.name !== teammate.name)
|
||||
members.push(teammate)
|
||||
return { ...config, members }
|
||||
}
|
||||
|
||||
export function removeTeammate(config: TeamConfig, agentName: string): TeamConfig {
|
||||
if (agentName === "team-lead") {
|
||||
throw new Error("cannot_remove_team_lead")
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
members: config.members.filter((member) => member.name !== agentName),
|
||||
}
|
||||
}
|
||||
|
||||
export function assignNextColor(config: TeamConfig): string {
|
||||
const teammateCount = listTeammates(config).length
|
||||
return TEAM_COLOR_PALETTE[teammateCount % TEAM_COLOR_PALETTE.length]
|
||||
}
|
||||
|
||||
export function deleteTeamData(teamName: string): void {
|
||||
assertValidTeamName(teamName)
|
||||
withTeamLock(teamName, () => {
|
||||
const config = readJsonSafe(getTeamConfigPath(teamName), TeamConfigSchema)
|
||||
if (!config) {
|
||||
throw new Error("team_not_found")
|
||||
}
|
||||
|
||||
if (listTeammates(config).length > 0) {
|
||||
throw new Error("team_has_active_members")
|
||||
}
|
||||
|
||||
withTeamTaskLock(teamName, () => {
|
||||
const teamDir = getTeamDir(teamName)
|
||||
const taskDir = getTeamTaskDir(teamName)
|
||||
|
||||
if (existsSync(taskDir)) {
|
||||
rmSync(taskDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
if (existsSync(teamDir)) {
|
||||
rmSync(teamDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteTeamDir(teamName: string): void {
|
||||
deleteTeamData(teamName)
|
||||
}
|
||||
|
||||
export function listTeams(): string[] {
|
||||
const teamsRootDir = getTeamsRootDir()
|
||||
if (!existsSync(teamsRootDir)) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = readdirSync(teamsRootDir, { withFileTypes: true })
|
||||
return entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.filter((name) => existsSync(getTeamConfigPath(name)))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import { existsSync, mkdtempSync, rmSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { createTeamCreateTool, createTeamDeleteTool } from "./team-lifecycle-tools"
|
||||
import { getTeamConfigPath, getTeamDir, getTeamTaskDir } from "./paths"
|
||||
import { readTeamConfig, listTeammates } from "./team-config-store"
|
||||
import { getTeamsRootDir, getTeamTasksRootDir } from "./paths"
|
||||
import { deleteTeamData } from "./team-config-store"
|
||||
|
||||
const TEST_SUFFIX = randomUUID().substring(0, 8)
|
||||
|
||||
interface TestToolContext {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
agent: string
|
||||
abort: AbortSignal
|
||||
}
|
||||
|
||||
function createContext(sessionID = "ses-main"): TestToolContext {
|
||||
return {
|
||||
sessionID,
|
||||
messageID: "msg-main",
|
||||
agent: "sisyphus",
|
||||
abort: new AbortController().signal as AbortSignal,
|
||||
}
|
||||
}
|
||||
|
||||
async function executeJsonTool(
|
||||
tool: ReturnType<typeof createTeamCreateTool | typeof createTeamDeleteTool>,
|
||||
args: Record<string, unknown>,
|
||||
context: TestToolContext,
|
||||
): Promise<unknown> {
|
||||
const output = await tool.execute(args, context)
|
||||
return JSON.parse(output)
|
||||
}
|
||||
|
||||
describe("team_lifecycle tools", () => {
|
||||
let originalCwd: string
|
||||
let tempProjectDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd()
|
||||
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-lifecycle-"))
|
||||
process.chdir(tempProjectDir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd)
|
||||
rmSync(tempProjectDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("team_create", () => {
|
||||
test("creates team with valid name and description", async () => {
|
||||
//#given
|
||||
const tool = createTeamCreateTool()
|
||||
const context = createContext()
|
||||
const teamName = `test-team-${TEST_SUFFIX}`
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(tool, {
|
||||
team_name: teamName,
|
||||
description: "My test team",
|
||||
}, context)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual({
|
||||
team_name: teamName,
|
||||
config_path: getTeamConfigPath(teamName),
|
||||
lead_agent_id: `team-lead@${teamName}`,
|
||||
})
|
||||
|
||||
// Verify team was actually created
|
||||
const teamConfig = readTeamConfig(teamName)
|
||||
expect(teamConfig).not.toBeNull()
|
||||
expect(teamConfig?.name).toBe(teamName)
|
||||
expect(teamConfig?.description).toBe("My test team")
|
||||
expect(teamConfig?.leadAgentId).toBe(`team-lead@${teamName}`)
|
||||
expect(teamConfig?.leadSessionId).toBe("ses-main")
|
||||
expect(teamConfig?.members).toHaveLength(1)
|
||||
expect(teamConfig?.members[0].agentType).toBe("team-lead")
|
||||
})
|
||||
|
||||
test("creates team with only name (description optional)", async () => {
|
||||
//#given
|
||||
const tool = createTeamCreateTool()
|
||||
const context = createContext()
|
||||
const teamName = `minimal-team-${TEST_SUFFIX}`
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(tool, {
|
||||
team_name: teamName,
|
||||
}, context)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual({
|
||||
team_name: teamName,
|
||||
config_path: getTeamConfigPath(teamName),
|
||||
lead_agent_id: `team-lead@${teamName}`,
|
||||
})
|
||||
|
||||
const teamConfig = readTeamConfig(teamName)
|
||||
expect(teamConfig?.description).toBe("")
|
||||
})
|
||||
|
||||
test("validates team name format (alphanumeric, hyphens, underscores only)", async () => {
|
||||
//#given
|
||||
const tool = createTeamCreateTool()
|
||||
const context = createContext()
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(tool, {
|
||||
team_name: "invalid@name",
|
||||
}, context)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual({
|
||||
error: "team_create_failed",
|
||||
})
|
||||
})
|
||||
|
||||
test("validates team name max length (64 chars)", async () => {
|
||||
//#given
|
||||
const tool = createTeamCreateTool()
|
||||
const context = createContext()
|
||||
const longName = "a".repeat(65)
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(tool, {
|
||||
team_name: longName,
|
||||
}, context)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual({
|
||||
error: "team_create_failed",
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects duplicate team names", async () => {
|
||||
//#given
|
||||
const tool = createTeamCreateTool()
|
||||
const context1 = createContext("ses-1")
|
||||
const context2 = createContext("ses-2")
|
||||
const teamName = `duplicate-team-${TEST_SUFFIX}`
|
||||
|
||||
// Create team first
|
||||
await executeJsonTool(tool, {
|
||||
team_name: teamName,
|
||||
}, context1)
|
||||
|
||||
//#when - try to create same team again
|
||||
const result = await executeJsonTool(tool, {
|
||||
team_name: teamName,
|
||||
}, context2)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual({
|
||||
error: "team_already_exists",
|
||||
})
|
||||
|
||||
// Verify first team still exists
|
||||
const teamConfig = readTeamConfig(teamName)
|
||||
expect(teamConfig).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("team_delete", () => {
|
||||
test("deletes team when no active teammates", async () => {
|
||||
//#given
|
||||
const createTool = createTeamCreateTool()
|
||||
const deleteTool = createTeamDeleteTool()
|
||||
const context = createContext()
|
||||
const teamName = `test-delete-team-${TEST_SUFFIX}`
|
||||
|
||||
// Create team first
|
||||
await executeJsonTool(createTool, {
|
||||
team_name: teamName,
|
||||
}, context)
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(deleteTool, {
|
||||
team_name: teamName,
|
||||
}, context)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual({
|
||||
deleted: true,
|
||||
team_name: teamName,
|
||||
})
|
||||
|
||||
// Verify team dir is deleted
|
||||
expect(existsSync(getTeamDir(teamName))).toBe(false)
|
||||
expect(existsSync(getTeamTaskDir(teamName))).toBe(false)
|
||||
expect(existsSync(getTeamConfigPath(teamName))).toBe(false)
|
||||
})
|
||||
|
||||
test("blocks deletion when team has active teammates", async () => {
|
||||
//#given
|
||||
const createTool = createTeamCreateTool()
|
||||
const deleteTool = createTeamDeleteTool()
|
||||
const context = createContext()
|
||||
const teamName = `team-with-members-${TEST_SUFFIX}`
|
||||
|
||||
// Create team
|
||||
await executeJsonTool(createTool, {
|
||||
team_name: teamName,
|
||||
}, context)
|
||||
|
||||
// Add a teammate by modifying config directly for test
|
||||
const teamConfig = readTeamConfig(teamName)
|
||||
expect(teamConfig).not.toBeNull()
|
||||
|
||||
// Manually add a teammate to simulate active member
|
||||
const { writeTeamConfig } = await import("./team-config-store")
|
||||
if (teamConfig) {
|
||||
writeTeamConfig(teamName, {
|
||||
...teamConfig,
|
||||
members: [
|
||||
...teamConfig.members,
|
||||
{
|
||||
agentId: "teammate-1",
|
||||
name: "test-teammate",
|
||||
agentType: "teammate",
|
||||
color: "#FF6B6B",
|
||||
category: "test",
|
||||
model: "test-model",
|
||||
prompt: "Test prompt",
|
||||
planModeRequired: false,
|
||||
joinedAt: new Date().toISOString(),
|
||||
cwd: "/tmp",
|
||||
subscriptions: [],
|
||||
backendType: "native",
|
||||
isActive: true,
|
||||
sessionID: "test-session",
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(deleteTool, {
|
||||
team_name: teamName,
|
||||
}, context)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual({
|
||||
error: "team_has_active_members",
|
||||
members: ["test-teammate"],
|
||||
})
|
||||
|
||||
// Cleanup - manually remove teammates first, then delete
|
||||
const configApi = await import("./team-config-store")
|
||||
const cleanupConfig = readTeamConfig(teamName)
|
||||
if (cleanupConfig) {
|
||||
configApi.writeTeamConfig(teamName, {
|
||||
...cleanupConfig,
|
||||
members: cleanupConfig.members.filter((m) => m.agentType === "team-lead"),
|
||||
})
|
||||
configApi.deleteTeamData(teamName)
|
||||
}
|
||||
})
|
||||
|
||||
test("validates team name format on deletion", async () => {
|
||||
//#given
|
||||
const deleteTool = createTeamDeleteTool()
|
||||
const context = createContext()
|
||||
const teamName = `invalid-team-${TEST_SUFFIX}`
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(deleteTool, {
|
||||
team_name: "invalid@name",
|
||||
}, context)
|
||||
|
||||
//#then - Zod returns detailed validation error array
|
||||
const parsedResult = result as { error: string }
|
||||
expect(parsedResult.error).toContain("Team name must contain only letters")
|
||||
})
|
||||
|
||||
test("returns error for non-existent team", async () => {
|
||||
//#given
|
||||
const deleteTool = createTeamDeleteTool()
|
||||
const context = createContext()
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(deleteTool, {
|
||||
team_name: "non-existent-team",
|
||||
}, context)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual({
|
||||
error: "team_not_found",
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,151 +0,0 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||
import { z } from "zod"
|
||||
import { getTeamConfigPath } from "./paths"
|
||||
import { validateTeamName } from "./name-validation"
|
||||
import { ensureInbox } from "./inbox-store"
|
||||
import {
|
||||
TeamConfig,
|
||||
TeamCreateInputSchema,
|
||||
TeamDeleteInputSchema,
|
||||
TeamReadConfigInputSchema,
|
||||
TeamToolContext,
|
||||
isTeammateMember,
|
||||
} from "./types"
|
||||
import { createTeamConfig, deleteTeamData, listTeammates, readTeamConfig, readTeamConfigOrThrow } from "./team-config-store"
|
||||
|
||||
function resolveReaderFromContext(config: TeamConfig, context: TeamToolContext): "team-lead" | string | null {
|
||||
if (context.sessionID === config.leadSessionId) {
|
||||
return "team-lead"
|
||||
}
|
||||
|
||||
const matchedMember = config.members.find((member) => isTeammateMember(member) && member.sessionID === context.sessionID)
|
||||
return matchedMember?.name ?? null
|
||||
}
|
||||
|
||||
function toPublicTeamConfig(config: TeamConfig): {
|
||||
team_name: string
|
||||
description: string | undefined
|
||||
lead_agent_id: string
|
||||
teammates: Array<{ name: string }>
|
||||
} {
|
||||
return {
|
||||
team_name: config.name,
|
||||
description: config.description,
|
||||
lead_agent_id: config.leadAgentId,
|
||||
teammates: listTeammates(config).map((member) => ({ name: member.name })),
|
||||
}
|
||||
}
|
||||
|
||||
export function createTeamCreateTool(): ToolDefinition {
|
||||
return tool({
|
||||
description: "Create a team workspace with config, inboxes, and task storage.",
|
||||
args: {
|
||||
team_name: tool.schema.string().describe("Team name (letters, numbers, hyphens, underscores)"),
|
||||
description: tool.schema.string().optional().describe("Team description"),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
|
||||
try {
|
||||
const input = TeamCreateInputSchema.parse(args)
|
||||
|
||||
const config = createTeamConfig(
|
||||
input.team_name,
|
||||
input.description ?? "",
|
||||
context.sessionID,
|
||||
process.cwd(),
|
||||
"native/team-lead",
|
||||
)
|
||||
ensureInbox(config.name, "team-lead")
|
||||
|
||||
return JSON.stringify({
|
||||
team_name: config.name,
|
||||
config_path: getTeamConfigPath(config.name) as string,
|
||||
lead_agent_id: config.leadAgentId,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "team_already_exists") {
|
||||
return JSON.stringify({ error: error.message })
|
||||
}
|
||||
return JSON.stringify({ error: "team_create_failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createTeamDeleteTool(): ToolDefinition {
|
||||
return tool({
|
||||
description: "Delete a team and its stored data. Fails if teammates still exist.",
|
||||
args: {
|
||||
team_name: tool.schema.string().describe("Team name"),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>, _context: TeamToolContext): Promise<string> => {
|
||||
let teamName: string | undefined
|
||||
|
||||
try {
|
||||
const input = TeamDeleteInputSchema.parse(args)
|
||||
teamName = input.team_name
|
||||
const config = readTeamConfig(input.team_name)
|
||||
if (!config) {
|
||||
return JSON.stringify({ error: "team_not_found" })
|
||||
}
|
||||
|
||||
const teammates = listTeammates(config)
|
||||
if (teammates.length > 0) {
|
||||
return JSON.stringify({
|
||||
error: "team_has_active_members",
|
||||
members: teammates.map((member) => member.name),
|
||||
})
|
||||
}
|
||||
|
||||
deleteTeamData(input.team_name)
|
||||
return JSON.stringify({ deleted: true, team_name: input.team_name })
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === "team_has_active_members") {
|
||||
const config = readTeamConfig(teamName!)
|
||||
const activeMembers = config ? listTeammates(config) : []
|
||||
return JSON.stringify({
|
||||
error: "team_has_active_members",
|
||||
members: activeMembers.map((member) => member.name),
|
||||
})
|
||||
}
|
||||
if (error.message === "team_not_found") {
|
||||
return JSON.stringify({ error: "team_not_found" })
|
||||
}
|
||||
return JSON.stringify({ error: error.message })
|
||||
}
|
||||
return JSON.stringify({ error: "team_delete_failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createTeamReadConfigTool(): ToolDefinition {
|
||||
return tool({
|
||||
description: "Read team configuration and member list.",
|
||||
args: {
|
||||
team_name: tool.schema.string().describe("Team name"),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
|
||||
try {
|
||||
const input = TeamReadConfigInputSchema.parse(args)
|
||||
const config = readTeamConfig(input.team_name)
|
||||
if (!config) {
|
||||
return JSON.stringify({ error: "team_not_found" })
|
||||
}
|
||||
|
||||
const actor = resolveReaderFromContext(config, context)
|
||||
if (!actor) {
|
||||
return JSON.stringify({ error: "unauthorized_reader_session" })
|
||||
}
|
||||
|
||||
if (actor !== "team-lead") {
|
||||
return JSON.stringify(toPublicTeamConfig(config))
|
||||
}
|
||||
|
||||
return JSON.stringify(config)
|
||||
} catch (error) {
|
||||
return JSON.stringify({ error: error instanceof Error ? error.message : "team_read_config_failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
addPendingEdge,
|
||||
createPendingEdgeMap,
|
||||
ensureDependenciesCompleted,
|
||||
ensureForwardStatusTransition,
|
||||
wouldCreateCycle,
|
||||
} from "./team-task-dependency"
|
||||
import type { TeamTask, TeamTaskStatus } from "./types"
|
||||
|
||||
function createTask(id: string, status: TeamTaskStatus, blockedBy: string[] = []): TeamTask {
|
||||
return {
|
||||
id,
|
||||
subject: `Task ${id}`,
|
||||
description: `Description ${id}`,
|
||||
status,
|
||||
blocks: [],
|
||||
blockedBy,
|
||||
}
|
||||
}
|
||||
|
||||
describe("agent-teams task dependency utilities", () => {
|
||||
test("detects cycle from existing blockedBy chain", () => {
|
||||
//#given
|
||||
const tasks = new Map<string, TeamTask>([
|
||||
["A", createTask("A", "pending", ["B"])],
|
||||
["B", createTask("B", "pending")],
|
||||
])
|
||||
const pending = createPendingEdgeMap()
|
||||
const readTask = (id: string) => tasks.get(id) ?? null
|
||||
|
||||
//#when
|
||||
const hasCycle = wouldCreateCycle("B", "A", pending, readTask)
|
||||
|
||||
//#then
|
||||
expect(hasCycle).toBe(true)
|
||||
})
|
||||
|
||||
test("detects cycle from pending edge map", () => {
|
||||
//#given
|
||||
const tasks = new Map<string, TeamTask>([["A", createTask("A", "pending")]])
|
||||
const pending = createPendingEdgeMap()
|
||||
addPendingEdge(pending, "A", "B")
|
||||
const readTask = (id: string) => tasks.get(id) ?? null
|
||||
|
||||
//#when
|
||||
const hasCycle = wouldCreateCycle("B", "A", pending, readTask)
|
||||
|
||||
//#then
|
||||
expect(hasCycle).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false when dependency graph has no cycle", () => {
|
||||
//#given
|
||||
const tasks = new Map<string, TeamTask>([
|
||||
["A", createTask("A", "pending")],
|
||||
["B", createTask("B", "pending", ["A"])],
|
||||
])
|
||||
const pending = createPendingEdgeMap()
|
||||
const readTask = (id: string) => tasks.get(id) ?? null
|
||||
|
||||
//#when
|
||||
const hasCycle = wouldCreateCycle("C", "B", pending, readTask)
|
||||
|
||||
//#then
|
||||
expect(hasCycle).toBe(false)
|
||||
})
|
||||
|
||||
test("allows forward status transitions and blocks backward transitions", () => {
|
||||
//#then
|
||||
expect(() => ensureForwardStatusTransition("pending", "in_progress")).not.toThrow()
|
||||
expect(() => ensureForwardStatusTransition("in_progress", "completed")).not.toThrow()
|
||||
expect(() => ensureForwardStatusTransition("in_progress", "pending")).toThrow(
|
||||
"invalid_status_transition:in_progress->pending",
|
||||
)
|
||||
})
|
||||
|
||||
test("requires blockers to be completed for in_progress/completed", () => {
|
||||
//#given
|
||||
const tasks = new Map<string, TeamTask>([
|
||||
["done", createTask("done", "completed")],
|
||||
["wait", createTask("wait", "pending")],
|
||||
])
|
||||
const readTask = (id: string) => tasks.get(id) ?? null
|
||||
|
||||
//#then
|
||||
expect(() => ensureDependenciesCompleted("pending", ["wait"], readTask)).not.toThrow()
|
||||
expect(() => ensureDependenciesCompleted("in_progress", ["done"], readTask)).not.toThrow()
|
||||
expect(() => ensureDependenciesCompleted("completed", ["wait"], readTask)).toThrow(
|
||||
"blocked_by_incomplete:wait:pending",
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,93 +0,0 @@
|
||||
import type { TeamTask, TeamTaskStatus } from "./types"
|
||||
|
||||
type PendingEdges = Record<string, Set<string>>
|
||||
|
||||
export const TEAM_TASK_STATUS_ORDER: Record<TeamTaskStatus, number> = {
|
||||
pending: 0,
|
||||
in_progress: 1,
|
||||
completed: 2,
|
||||
deleted: 3,
|
||||
}
|
||||
|
||||
export type TaskReader = (taskId: string) => TeamTask | null
|
||||
|
||||
export function wouldCreateCycle(
|
||||
fromTaskId: string,
|
||||
toTaskId: string,
|
||||
pendingEdges: PendingEdges,
|
||||
readTask: TaskReader,
|
||||
): boolean {
|
||||
const visited = new Set<string>()
|
||||
const queue: string[] = [toTaskId]
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()
|
||||
if (!current) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (current === fromTaskId) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (visited.has(current)) {
|
||||
continue
|
||||
}
|
||||
visited.add(current)
|
||||
|
||||
const task = readTask(current)
|
||||
if (task) {
|
||||
for (const dep of task.blockedBy) {
|
||||
if (!visited.has(dep)) {
|
||||
queue.push(dep)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pending = pendingEdges[current]
|
||||
if (pending) {
|
||||
for (const dep of pending) {
|
||||
if (!visited.has(dep)) {
|
||||
queue.push(dep)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function ensureForwardStatusTransition(current: TeamTaskStatus, next: TeamTaskStatus): void {
|
||||
const currentOrder = TEAM_TASK_STATUS_ORDER[current]
|
||||
const nextOrder = TEAM_TASK_STATUS_ORDER[next]
|
||||
if (nextOrder < currentOrder) {
|
||||
throw new Error(`invalid_status_transition:${current}->${next}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureDependenciesCompleted(
|
||||
status: TeamTaskStatus,
|
||||
blockedBy: string[],
|
||||
readTask: TaskReader,
|
||||
): void {
|
||||
if (status !== "in_progress" && status !== "completed") {
|
||||
return
|
||||
}
|
||||
|
||||
for (const blockerId of blockedBy) {
|
||||
const blocker = readTask(blockerId)
|
||||
if (blocker && blocker.status !== "completed") {
|
||||
throw new Error(`blocked_by_incomplete:${blockerId}:${blocker.status}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createPendingEdgeMap(): PendingEdges {
|
||||
return {}
|
||||
}
|
||||
|
||||
export function addPendingEdge(pendingEdges: PendingEdges, from: string, to: string): void {
|
||||
const existing = pendingEdges[from] ?? new Set<string>()
|
||||
existing.add(to)
|
||||
pendingEdges[from] = existing
|
||||
}
|
||||
@@ -1,460 +0,0 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { dirname } from "node:path"
|
||||
import { ensureDir } from "../../features/claude-tasks/storage"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import {
|
||||
getTeamTaskPath,
|
||||
readTeamTask,
|
||||
writeTeamTask,
|
||||
listTeamTasks,
|
||||
deleteTeamTask,
|
||||
} from "./team-task-store"
|
||||
import type { TeamTask } from "./types"
|
||||
|
||||
describe("getTeamTaskPath", () => {
|
||||
test("returns correct file path for team task", () => {
|
||||
//#given
|
||||
const teamName = "my-team"
|
||||
const taskId = "T-abc123"
|
||||
|
||||
//#when
|
||||
const result = getTeamTaskPath(teamName, taskId)
|
||||
|
||||
//#then
|
||||
expect(result).toContain("my-team")
|
||||
expect(result).toContain("T-abc123.json")
|
||||
})
|
||||
})
|
||||
|
||||
describe("readTeamTask", () => {
|
||||
let originalCwd: string
|
||||
let tempProjectDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd()
|
||||
tempProjectDir = mkdtempSync(join(tmpdir(), "team-task-store-test-"))
|
||||
process.chdir(tempProjectDir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd)
|
||||
if (existsSync(tempProjectDir)) {
|
||||
rmSync(tempProjectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("returns null when task file does not exist", () => {
|
||||
//#given
|
||||
const teamName = "nonexistent-team"
|
||||
const taskId = "T-does-not-exist"
|
||||
|
||||
//#when
|
||||
const result = readTeamTask(teamName, taskId)
|
||||
|
||||
//#then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test("returns task when valid task file exists", () => {
|
||||
//#given
|
||||
const task: TeamTask = {
|
||||
id: "T-existing-task",
|
||||
subject: "Test task",
|
||||
description: "Test description",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_test",
|
||||
}
|
||||
writeTeamTask("test-team", "T-existing-task", task)
|
||||
|
||||
//#when
|
||||
const result = readTeamTask("test-team", "T-existing-task")
|
||||
|
||||
//#then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.id).toBe("T-existing-task")
|
||||
expect(result?.subject).toBe("Test task")
|
||||
})
|
||||
|
||||
test("returns null when task file contains invalid JSON", () => {
|
||||
//#given
|
||||
const taskPath = getTeamTaskPath("invalid-team", "T-invalid-json")
|
||||
const parentDir = dirname(taskPath)
|
||||
rmSync(parentDir, { recursive: true, force: true })
|
||||
ensureDir(parentDir)
|
||||
writeFileSync(taskPath, "{ invalid json }")
|
||||
|
||||
//#when
|
||||
const result = readTeamTask("invalid-team", "T-invalid-json")
|
||||
|
||||
//#then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null when task file does not match schema", () => {
|
||||
//#given
|
||||
const taskPath = getTeamTaskPath("invalid-schema-team", "T-bad-schema")
|
||||
const parentDir = dirname(taskPath)
|
||||
rmSync(parentDir, { recursive: true, force: true })
|
||||
ensureDir(parentDir)
|
||||
const invalidData = { id: "T-bad-schema" }
|
||||
writeFileSync(taskPath, JSON.stringify(invalidData))
|
||||
|
||||
//#when
|
||||
const result = readTeamTask("invalid-schema-team", "T-bad-schema")
|
||||
|
||||
//#then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("writeTeamTask", () => {
|
||||
let originalCwd: string
|
||||
let tempProjectDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd()
|
||||
tempProjectDir = mkdtempSync(join(tmpdir(), "team-task-store-test-"))
|
||||
process.chdir(tempProjectDir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd)
|
||||
if (existsSync(tempProjectDir)) {
|
||||
rmSync(tempProjectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("creates task file in team namespace", () => {
|
||||
//#given
|
||||
const task: TeamTask = {
|
||||
id: "T-write-test",
|
||||
subject: "Write test task",
|
||||
description: "Test writing task",
|
||||
status: "in_progress",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_write",
|
||||
}
|
||||
|
||||
//#when
|
||||
writeTeamTask("write-team", "T-write-test", task)
|
||||
|
||||
//#then
|
||||
const taskPath = getTeamTaskPath("write-team", "T-write-test")
|
||||
expect(existsSync(taskPath)).toBe(true)
|
||||
})
|
||||
|
||||
test("overwrites existing task file", () => {
|
||||
//#given
|
||||
const task: TeamTask = {
|
||||
id: "T-overwrite-test",
|
||||
subject: "Original subject",
|
||||
description: "Original description",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_original",
|
||||
}
|
||||
writeTeamTask("overwrite-team", "T-overwrite-test", task)
|
||||
|
||||
const updatedTask: TeamTask = {
|
||||
...task,
|
||||
subject: "Updated subject",
|
||||
status: "completed",
|
||||
}
|
||||
|
||||
//#when
|
||||
writeTeamTask("overwrite-team", "T-overwrite-test", updatedTask)
|
||||
|
||||
//#then
|
||||
const result = readTeamTask("overwrite-team", "T-overwrite-test")
|
||||
expect(result?.subject).toBe("Updated subject")
|
||||
expect(result?.status).toBe("completed")
|
||||
})
|
||||
|
||||
test("creates team directory if it does not exist", () => {
|
||||
//#given
|
||||
const task: TeamTask = {
|
||||
id: "T-new-dir",
|
||||
subject: "New directory test",
|
||||
description: "Test",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_newdir",
|
||||
}
|
||||
|
||||
//#when
|
||||
writeTeamTask("new-team-directory", "T-new-dir", task)
|
||||
|
||||
//#then
|
||||
const taskPath = getTeamTaskPath("new-team-directory", "T-new-dir")
|
||||
expect(existsSync(taskPath)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("listTeamTasks", () => {
|
||||
let originalCwd: string
|
||||
let tempProjectDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd()
|
||||
tempProjectDir = mkdtempSync(join(tmpdir(), "team-task-store-test-"))
|
||||
process.chdir(tempProjectDir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd)
|
||||
if (existsSync(tempProjectDir)) {
|
||||
rmSync(tempProjectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("returns empty array when team has no tasks", () => {
|
||||
//#given
|
||||
// No tasks written
|
||||
|
||||
//#when
|
||||
const result = listTeamTasks("empty-team")
|
||||
|
||||
//#then
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
test("returns all tasks for a team", () => {
|
||||
//#given
|
||||
const task1: TeamTask = {
|
||||
id: "T-task-1",
|
||||
subject: "Task 1",
|
||||
description: "First task",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_1",
|
||||
}
|
||||
const task2: TeamTask = {
|
||||
id: "T-task-2",
|
||||
subject: "Task 2",
|
||||
description: "Second task",
|
||||
status: "in_progress",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_2",
|
||||
}
|
||||
writeTeamTask("list-test-team", "T-task-1", task1)
|
||||
writeTeamTask("list-test-team", "T-task-2", task2)
|
||||
|
||||
//#when
|
||||
const result = listTeamTasks("list-test-team")
|
||||
|
||||
//#then
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result.some((t) => t.id === "T-task-1")).toBe(true)
|
||||
expect(result.some((t) => t.id === "T-task-2")).toBe(true)
|
||||
})
|
||||
|
||||
test("includes tasks with all statuses", () => {
|
||||
//#given
|
||||
const pendingTask: TeamTask = {
|
||||
id: "T-pending",
|
||||
subject: "Pending task",
|
||||
description: "Test",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_pending",
|
||||
}
|
||||
const inProgressTask: TeamTask = {
|
||||
id: "T-in-progress",
|
||||
subject: "In progress task",
|
||||
description: "Test",
|
||||
status: "in_progress",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_inprogress",
|
||||
}
|
||||
const completedTask: TeamTask = {
|
||||
id: "T-completed",
|
||||
subject: "Completed task",
|
||||
description: "Test",
|
||||
status: "completed",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_completed",
|
||||
}
|
||||
const deletedTask: TeamTask = {
|
||||
id: "T-deleted",
|
||||
subject: "Deleted task",
|
||||
description: "Test",
|
||||
status: "deleted",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_deleted",
|
||||
}
|
||||
writeTeamTask("status-test-team", "T-pending", pendingTask)
|
||||
writeTeamTask("status-test-team", "T-in-progress", inProgressTask)
|
||||
writeTeamTask("status-test-team", "T-completed", completedTask)
|
||||
writeTeamTask("status-test-team", "T-deleted", deletedTask)
|
||||
|
||||
//#when
|
||||
const result = listTeamTasks("status-test-team")
|
||||
|
||||
//#then
|
||||
expect(result).toHaveLength(4)
|
||||
const statuses = result.map((t) => t.status)
|
||||
expect(statuses).toContain("pending")
|
||||
expect(statuses).toContain("in_progress")
|
||||
expect(statuses).toContain("completed")
|
||||
expect(statuses).toContain("deleted")
|
||||
})
|
||||
|
||||
test("does not include tasks from other teams", () => {
|
||||
//#given
|
||||
const taskTeam1: TeamTask = {
|
||||
id: "T-team1-task",
|
||||
subject: "Team 1 task",
|
||||
description: "Test",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_team1",
|
||||
}
|
||||
const taskTeam2: TeamTask = {
|
||||
id: "T-team2-task",
|
||||
subject: "Team 2 task",
|
||||
description: "Test",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_team2",
|
||||
}
|
||||
writeTeamTask("team-1", "T-team1-task", taskTeam1)
|
||||
writeTeamTask("team-2", "T-team2-task", taskTeam2)
|
||||
|
||||
//#when
|
||||
const result = listTeamTasks("team-1")
|
||||
|
||||
//#then
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("T-team1-task")
|
||||
})
|
||||
})
|
||||
|
||||
describe("deleteTeamTask", () => {
|
||||
let originalCwd: string
|
||||
let tempProjectDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd()
|
||||
tempProjectDir = mkdtempSync(join(tmpdir(), "team-task-store-test-"))
|
||||
process.chdir(tempProjectDir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd)
|
||||
if (existsSync(tempProjectDir)) {
|
||||
rmSync(tempProjectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("deletes existing task file", () => {
|
||||
//#given
|
||||
const task: TeamTask = {
|
||||
id: "T-delete-me",
|
||||
subject: "Delete this task",
|
||||
description: "Test",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_delete",
|
||||
}
|
||||
writeTeamTask("delete-test-team", "T-delete-me", task)
|
||||
const taskPath = getTeamTaskPath("delete-test-team", "T-delete-me")
|
||||
|
||||
//#when
|
||||
deleteTeamTask("delete-test-team", "T-delete-me")
|
||||
|
||||
//#then
|
||||
expect(existsSync(taskPath)).toBe(false)
|
||||
})
|
||||
|
||||
test("does not throw when task does not exist", () => {
|
||||
//#given
|
||||
// Task does not exist
|
||||
|
||||
//#when
|
||||
expect(() => deleteTeamTask("nonexistent-team", "T-does-not-exist")).not.toThrow()
|
||||
|
||||
//#then
|
||||
// No exception thrown
|
||||
})
|
||||
|
||||
test("does not affect other tasks in same team", () => {
|
||||
//#given
|
||||
const task1: TeamTask = {
|
||||
id: "T-keep-me",
|
||||
subject: "Keep this task",
|
||||
description: "Test",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_keep",
|
||||
}
|
||||
const task2: TeamTask = {
|
||||
id: "T-delete-me",
|
||||
subject: "Delete this task",
|
||||
description: "Test",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_delete",
|
||||
}
|
||||
writeTeamTask("mixed-test-team", "T-keep-me", task1)
|
||||
writeTeamTask("mixed-test-team", "T-delete-me", task2)
|
||||
|
||||
//#when
|
||||
deleteTeamTask("mixed-test-team", "T-delete-me")
|
||||
|
||||
//#then
|
||||
const remaining = listTeamTasks("mixed-test-team")
|
||||
expect(remaining).toHaveLength(1)
|
||||
expect(remaining[0].id).toBe("T-keep-me")
|
||||
})
|
||||
|
||||
test("does not affect tasks from other teams", () => {
|
||||
//#given
|
||||
const task1: TeamTask = {
|
||||
id: "T-task-1",
|
||||
subject: "Task 1",
|
||||
description: "Test",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_1",
|
||||
}
|
||||
const task2: TeamTask = {
|
||||
id: "T-task-2",
|
||||
subject: "Task 2",
|
||||
description: "Test",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_2",
|
||||
}
|
||||
writeTeamTask("team-a", "T-task-1", task1)
|
||||
writeTeamTask("team-b", "T-task-2", task2)
|
||||
|
||||
//#when
|
||||
deleteTeamTask("team-a", "T-task-1")
|
||||
|
||||
//#then
|
||||
const remainingInTeamB = listTeamTasks("team-b")
|
||||
expect(remainingInTeamB).toHaveLength(1)
|
||||
expect(remainingInTeamB[0].id).toBe("T-task-2")
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user