Compare commits
52 Commits
feat/nativ
...
v3.5.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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.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",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -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.3", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Dq0+PC2dyAqG7c3DUnQmdOkKbKmOsRHwoqgLCQNKN1lTRllF8zbWqp5B+LGKxSPxPqJIPS3mKt+wIR2KvkYJVw=="],
|
||||
|
||||
"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.3", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Ke45Bv/ygZm3YUSUumIyk647KZ2PFzw30tH597cOpG8MDPGbNVBCM6EKFezcukUPT+gPFVpE1IiGzEkn4JmgZA=="],
|
||||
|
||||
"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.3", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-aP5S3DngUhFkNeqYM33Ge6zccCWLzB/O3FLXLFXy/Iws03N8xugw72pnMK6lUbIia9QQBKK7IZBoYm9C79pZ3g=="],
|
||||
|
||||
"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.3", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-UiD/hVKYZQyX4D5N5SnZT4M5Z/B2SDtJWBW4MibpYSAcPKNCEBKi/5E4hOPxAtTfFGR8tIXFmYZdQJDkVfvluw=="],
|
||||
|
||||
"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.3", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-L9kqwzElGkaQ8pgtv1ZjcHARw9LPaU4UEVjzauByTMi+/5Js/PTsNXBggxSRzZfQ8/MNBPSCiA4K10Kc0YjjvA=="],
|
||||
|
||||
"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.3", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Z0fVVih/b2dbNeb9DK9oca5dNYCZyPySBRtxRhDXod5d7fJNgIPrvUoEd3SNfkRGORyFB3hGBZ6nqQ6N8+8DEA=="],
|
||||
|
||||
"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.3", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-ocWPjRs2sJgN02PJnEIYtqdMVDex1YhEj1FzAU5XIicfzQbgxLh9nz1yhHZzfqGJq69QStU6ofpc5kQpfX1LMg=="],
|
||||
|
||||
"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.5",
|
||||
"description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -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.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"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@ast-grep/cli",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-arm64",
|
||||
"version": "3.5.3",
|
||||
"version": "3.5.5",
|
||||
"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.5",
|
||||
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-arm64-musl",
|
||||
"version": "3.5.3",
|
||||
"version": "3.5.5",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-arm64",
|
||||
"version": "3.5.3",
|
||||
"version": "3.5.5",
|
||||
"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.5",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64",
|
||||
"version": "3.5.3",
|
||||
"version": "3.5.5",
|
||||
"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.5",
|
||||
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1471,6 +1471,38 @@
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2289,10 +2289,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", () => {
|
||||
@@ -3202,4 +3413,44 @@ 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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) => {
|
||||
@@ -535,6 +542,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 +598,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 +660,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
|
||||
@@ -1252,6 +1266,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 }],
|
||||
},
|
||||
})
|
||||
@@ -1423,24 +1438,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 +1499,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 +1512,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 +1527,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 {
|
||||
|
||||
@@ -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,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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
@@ -9,10 +10,13 @@ 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 +56,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 +132,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 +146,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))
|
||||
@@ -510,7 +541,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,7 +549,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
|
||||
//#then
|
||||
expect(promptCalls).toHaveLength(2)
|
||||
})
|
||||
}, { timeout: 15000 })
|
||||
|
||||
test("should keep injecting even when todos remain unchanged across cycles", async () => {
|
||||
//#given
|
||||
@@ -534,26 +565,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 +644,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
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -1,165 +0,0 @@
|
||||
import { existsSync, readdirSync, unlinkSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import {
|
||||
acquireLock,
|
||||
ensureDir,
|
||||
generateTaskId,
|
||||
readJsonSafe,
|
||||
writeJsonAtomic,
|
||||
} from "../../features/claude-tasks/storage"
|
||||
import { getTeamTaskDir, getTeamTaskPath } from "./paths"
|
||||
import { TeamTask, TeamTaskSchema } from "./types"
|
||||
import { validateTaskId, validateTeamName } from "./name-validation"
|
||||
|
||||
function assertValidTeamName(teamName: string): void {
|
||||
const validationError = validateTeamName(teamName)
|
||||
if (validationError) {
|
||||
throw new Error(validationError)
|
||||
}
|
||||
}
|
||||
|
||||
function assertValidTaskId(taskId: string): void {
|
||||
const validationError = validateTaskId(taskId)
|
||||
if (validationError) {
|
||||
throw new Error(validationError)
|
||||
}
|
||||
}
|
||||
|
||||
function withTaskLock<T>(teamName: string, operation: () => T): T {
|
||||
assertValidTeamName(teamName)
|
||||
const taskDir = getTeamTaskDir(teamName)
|
||||
ensureDir(taskDir)
|
||||
const lock = acquireLock(taskDir)
|
||||
if (!lock.acquired) {
|
||||
throw new Error("team_task_lock_unavailable")
|
||||
}
|
||||
|
||||
try {
|
||||
return operation()
|
||||
} finally {
|
||||
lock.release()
|
||||
}
|
||||
}
|
||||
|
||||
export { getTeamTaskPath } from "./paths"
|
||||
|
||||
export function readTeamTask(teamName: string, taskId: string): TeamTask | null {
|
||||
assertValidTeamName(teamName)
|
||||
assertValidTaskId(taskId)
|
||||
return readJsonSafe(getTeamTaskPath(teamName, taskId), TeamTaskSchema)
|
||||
}
|
||||
|
||||
export function readTeamTaskOrThrow(teamName: string, taskId: string): TeamTask {
|
||||
const task = readTeamTask(teamName, taskId)
|
||||
if (!task) {
|
||||
throw new Error("team_task_not_found")
|
||||
}
|
||||
return task
|
||||
}
|
||||
|
||||
export function listTeamTasks(teamName: string): TeamTask[] {
|
||||
assertValidTeamName(teamName)
|
||||
const taskDir = getTeamTaskDir(teamName)
|
||||
if (!existsSync(taskDir)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const files = readdirSync(taskDir)
|
||||
.filter((file) => file.endsWith(".json") && file.startsWith("T-"))
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
|
||||
const tasks: TeamTask[] = []
|
||||
for (const file of files) {
|
||||
const taskId = file.replace(/\.json$/, "")
|
||||
if (validateTaskId(taskId)) {
|
||||
continue
|
||||
}
|
||||
const task = readTeamTask(teamName, taskId)
|
||||
if (task) {
|
||||
tasks.push(task)
|
||||
}
|
||||
}
|
||||
|
||||
return tasks
|
||||
}
|
||||
|
||||
export function createTeamTask(
|
||||
teamName: string,
|
||||
subject: string,
|
||||
description: string,
|
||||
activeForm?: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
): TeamTask {
|
||||
assertValidTeamName(teamName)
|
||||
if (!subject.trim()) {
|
||||
throw new Error("team_task_subject_required")
|
||||
}
|
||||
|
||||
return withTaskLock(teamName, () => {
|
||||
const taskId = generateTaskId()
|
||||
const task: TeamTask = {
|
||||
id: taskId,
|
||||
subject,
|
||||
description,
|
||||
activeForm,
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: `unknown_${taskId}`,
|
||||
...(metadata ? { metadata } : {}),
|
||||
}
|
||||
|
||||
const validated = TeamTaskSchema.parse(task)
|
||||
writeJsonAtomic(getTeamTaskPath(teamName, taskId), validated)
|
||||
return validated
|
||||
})
|
||||
}
|
||||
|
||||
export function writeTeamTask(teamName: string, taskId: string, task: TeamTask): void {
|
||||
assertValidTeamName(teamName)
|
||||
assertValidTaskId(taskId)
|
||||
const validated = TeamTaskSchema.parse(task)
|
||||
writeJsonAtomic(getTeamTaskPath(teamName, taskId), validated)
|
||||
}
|
||||
|
||||
export function deleteTeamTask(teamName: string, taskId: string): void {
|
||||
assertValidTeamName(teamName)
|
||||
assertValidTaskId(taskId)
|
||||
const taskPath = getTeamTaskPath(teamName, taskId)
|
||||
if (existsSync(taskPath)) {
|
||||
unlinkSync(taskPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Backward compatibility alias
|
||||
export function deleteTeamTaskFile(teamName: string, taskId: string): void {
|
||||
deleteTeamTask(teamName, taskId)
|
||||
}
|
||||
|
||||
export function readTaskFromDirectory(taskDir: string, taskId: string): TeamTask | null {
|
||||
assertValidTaskId(taskId)
|
||||
return readJsonSafe(join(taskDir, `${taskId}.json`), TeamTaskSchema)
|
||||
}
|
||||
|
||||
export function resetOwnerTasks(teamName: string, ownerName: string): void {
|
||||
assertValidTeamName(teamName)
|
||||
withTaskLock(teamName, () => {
|
||||
const tasks = listTeamTasks(teamName)
|
||||
for (const task of tasks) {
|
||||
if (task.owner !== ownerName) {
|
||||
continue
|
||||
}
|
||||
const next: TeamTask = {
|
||||
...task,
|
||||
owner: undefined,
|
||||
status: task.status === "completed" ? "completed" : "pending",
|
||||
}
|
||||
writeTeamTask(teamName, next.id, next)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function withTeamTaskLock<T>(teamName: string, operation: () => T): T {
|
||||
assertValidTeamName(teamName)
|
||||
return withTaskLock(teamName, operation)
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||
import { sendStructuredInboxMessage } from "./inbox-store"
|
||||
import { readTeamConfigOrThrow } from "./team-config-store"
|
||||
import { validateAgentNameOrLead, validateTaskId, validateTeamName } from "./name-validation"
|
||||
import {
|
||||
TeamConfig,
|
||||
TeamTaskCreateInputSchema,
|
||||
TeamTaskGetInputSchema,
|
||||
TeamTaskListInputSchema,
|
||||
TeamTask,
|
||||
TeamToolContext,
|
||||
isTeammateMember,
|
||||
} from "./types"
|
||||
import { createTeamTask, listTeamTasks, readTeamTask } from "./team-task-store"
|
||||
|
||||
function buildTaskAssignmentPayload(task: TeamTask, assignedBy: string): Record<string, unknown> {
|
||||
return {
|
||||
type: "task_assignment",
|
||||
taskId: task.id,
|
||||
subject: task.subject,
|
||||
description: task.description,
|
||||
assignedBy,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveTaskActorFromContext(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
|
||||
}
|
||||
|
||||
export function createTeamTaskCreateTool(): ToolDefinition {
|
||||
return tool({
|
||||
description: "Create a task in team-scoped storage.",
|
||||
args: {
|
||||
team_name: tool.schema.string().describe("Team name"),
|
||||
subject: tool.schema.string().describe("Task subject"),
|
||||
description: tool.schema.string().describe("Task description"),
|
||||
active_form: tool.schema.string().optional().describe("Present-continuous form"),
|
||||
metadata: tool.schema.record(tool.schema.string(), tool.schema.unknown()).optional().describe("Task metadata"),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
|
||||
try {
|
||||
const input = TeamTaskCreateInputSchema.parse(args)
|
||||
const teamError = validateTeamName(input.team_name)
|
||||
if (teamError) {
|
||||
return JSON.stringify({ error: teamError })
|
||||
}
|
||||
const config = readTeamConfigOrThrow(input.team_name)
|
||||
const actor = resolveTaskActorFromContext(config, context)
|
||||
if (!actor) {
|
||||
return JSON.stringify({ error: "unauthorized_task_session" })
|
||||
}
|
||||
|
||||
const task = createTeamTask(
|
||||
input.team_name,
|
||||
input.subject,
|
||||
input.description,
|
||||
input.active_form,
|
||||
input.metadata,
|
||||
)
|
||||
|
||||
return JSON.stringify(task)
|
||||
} catch (error) {
|
||||
return JSON.stringify({ error: error instanceof Error ? error.message : "team_task_create_failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createTeamTaskListTool(): ToolDefinition {
|
||||
return tool({
|
||||
description: "List tasks for one team.",
|
||||
args: {
|
||||
team_name: tool.schema.string().describe("Team name"),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
|
||||
try {
|
||||
const input = TeamTaskListInputSchema.parse(args)
|
||||
const teamError = validateTeamName(input.team_name)
|
||||
if (teamError) {
|
||||
return JSON.stringify({ error: teamError })
|
||||
}
|
||||
const config = readTeamConfigOrThrow(input.team_name)
|
||||
const actor = resolveTaskActorFromContext(config, context)
|
||||
if (!actor) {
|
||||
return JSON.stringify({ error: "unauthorized_task_session" })
|
||||
}
|
||||
return JSON.stringify(listTeamTasks(input.team_name))
|
||||
} catch (error) {
|
||||
return JSON.stringify({ error: error instanceof Error ? error.message : "team_task_list_failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createTeamTaskGetTool(): ToolDefinition {
|
||||
return tool({
|
||||
description: "Get one task from team-scoped storage.",
|
||||
args: {
|
||||
team_name: tool.schema.string().describe("Team name"),
|
||||
task_id: tool.schema.string().describe("Task id"),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
|
||||
try {
|
||||
const input = TeamTaskGetInputSchema.parse(args)
|
||||
const teamError = validateTeamName(input.team_name)
|
||||
if (teamError) {
|
||||
return JSON.stringify({ error: teamError })
|
||||
}
|
||||
const taskIdError = validateTaskId(input.task_id)
|
||||
if (taskIdError) {
|
||||
return JSON.stringify({ error: taskIdError })
|
||||
}
|
||||
const config = readTeamConfigOrThrow(input.team_name)
|
||||
const actor = resolveTaskActorFromContext(config, context)
|
||||
if (!actor) {
|
||||
return JSON.stringify({ error: "unauthorized_task_session" })
|
||||
}
|
||||
const task = readTeamTask(input.team_name, input.task_id)
|
||||
if (!task) {
|
||||
return JSON.stringify({ error: "team_task_not_found" })
|
||||
}
|
||||
return JSON.stringify(task)
|
||||
} catch (error) {
|
||||
return JSON.stringify({ error: error instanceof Error ? error.message : "team_task_get_failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function notifyOwnerAssignment(teamName: string, task: TeamTask, assignedBy: string): void {
|
||||
if (!task.owner || task.status === "deleted") {
|
||||
return
|
||||
}
|
||||
|
||||
if (validateTeamName(teamName)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (validateAgentNameOrLead(task.owner)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (validateAgentNameOrLead(assignedBy)) {
|
||||
return
|
||||
}
|
||||
|
||||
sendStructuredInboxMessage(
|
||||
teamName,
|
||||
assignedBy,
|
||||
task.owner,
|
||||
buildTaskAssignmentPayload(task, assignedBy),
|
||||
"task_assignment",
|
||||
)
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||
import { readTeamConfigOrThrow } from "./team-config-store"
|
||||
import { validateAgentNameOrLead, validateTaskId, validateTeamName } from "./name-validation"
|
||||
import { TeamTaskUpdateInputSchema, TeamToolContext } from "./types"
|
||||
import { updateTeamTask } from "./team-task-update"
|
||||
import { notifyOwnerAssignment, resolveTaskActorFromContext } from "./team-task-tools"
|
||||
|
||||
export function createTeamTaskUpdateTool(): ToolDefinition {
|
||||
return tool({
|
||||
description: "Update task status, owner, dependencies, and metadata in a team task list.",
|
||||
args: {
|
||||
team_name: tool.schema.string().describe("Team name"),
|
||||
task_id: tool.schema.string().describe("Task id"),
|
||||
status: tool.schema.enum(["pending", "in_progress", "completed", "deleted"]).optional().describe("Task status"),
|
||||
owner: tool.schema.string().optional().describe("Task owner"),
|
||||
subject: tool.schema.string().optional().describe("Task subject"),
|
||||
description: tool.schema.string().optional().describe("Task description"),
|
||||
active_form: tool.schema.string().optional().describe("Present-continuous form"),
|
||||
add_blocks: tool.schema.array(tool.schema.string()).optional().describe("Add task ids this task blocks"),
|
||||
add_blocked_by: tool.schema.array(tool.schema.string()).optional().describe("Add blocker task ids"),
|
||||
metadata: tool.schema.record(tool.schema.string(), tool.schema.unknown()).optional().describe("Metadata patch (null removes key)"),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
|
||||
try {
|
||||
const input = TeamTaskUpdateInputSchema.parse(args)
|
||||
const teamError = validateTeamName(input.team_name)
|
||||
if (teamError) {
|
||||
return JSON.stringify({ error: teamError })
|
||||
}
|
||||
const taskIdError = validateTaskId(input.task_id)
|
||||
if (taskIdError) {
|
||||
return JSON.stringify({ error: taskIdError })
|
||||
}
|
||||
|
||||
const config = readTeamConfigOrThrow(input.team_name)
|
||||
const actor = resolveTaskActorFromContext(config, context)
|
||||
if (!actor) {
|
||||
return JSON.stringify({ error: "unauthorized_task_session" })
|
||||
}
|
||||
|
||||
const memberNames = new Set(config.members.map((member) => member.name))
|
||||
if (input.owner !== undefined) {
|
||||
if (input.owner !== "") {
|
||||
const ownerError = validateAgentNameOrLead(input.owner)
|
||||
if (ownerError) {
|
||||
return JSON.stringify({ error: ownerError })
|
||||
}
|
||||
|
||||
if (!memberNames.has(input.owner)) {
|
||||
return JSON.stringify({ error: "owner_not_in_team" })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (input.add_blocks) {
|
||||
for (const blockerId of input.add_blocks) {
|
||||
const blockerError = validateTaskId(blockerId)
|
||||
if (blockerError) {
|
||||
return JSON.stringify({ error: blockerError })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (input.add_blocked_by) {
|
||||
for (const dependencyId of input.add_blocked_by) {
|
||||
const dependencyError = validateTaskId(dependencyId)
|
||||
if (dependencyError) {
|
||||
return JSON.stringify({ error: dependencyError })
|
||||
}
|
||||
}
|
||||
}
|
||||
const task = updateTeamTask(input.team_name, input.task_id, {
|
||||
status: input.status,
|
||||
owner: input.owner,
|
||||
subject: input.subject,
|
||||
description: input.description,
|
||||
activeForm: input.active_form,
|
||||
addBlocks: input.add_blocks,
|
||||
addBlockedBy: input.add_blocked_by,
|
||||
metadata: input.metadata,
|
||||
})
|
||||
|
||||
if (input.owner !== undefined) {
|
||||
notifyOwnerAssignment(input.team_name, task, actor)
|
||||
}
|
||||
|
||||
return JSON.stringify(task)
|
||||
} catch (error) {
|
||||
return JSON.stringify({ error: error instanceof Error ? error.message : "team_task_update_failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
import { existsSync, readdirSync, unlinkSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { readJsonSafe, writeJsonAtomic } from "../../features/claude-tasks/storage"
|
||||
import { validateTaskId, validateTeamName } from "./name-validation"
|
||||
import { getTeamTaskDir, getTeamTaskPath } from "./paths"
|
||||
import {
|
||||
addPendingEdge,
|
||||
createPendingEdgeMap,
|
||||
ensureDependenciesCompleted,
|
||||
ensureForwardStatusTransition,
|
||||
wouldCreateCycle,
|
||||
} from "./team-task-dependency"
|
||||
import { TeamTask, TeamTaskSchema, TeamTaskStatus } from "./types"
|
||||
import { withTeamTaskLock } from "./team-task-store"
|
||||
|
||||
export interface TeamTaskUpdatePatch {
|
||||
status?: TeamTaskStatus
|
||||
owner?: string
|
||||
subject?: string
|
||||
description?: string
|
||||
activeForm?: string
|
||||
addBlocks?: string[]
|
||||
addBlockedBy?: string[]
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function assertValidTeamName(teamName: string): void {
|
||||
const validationError = validateTeamName(teamName)
|
||||
if (validationError) {
|
||||
throw new Error(validationError)
|
||||
}
|
||||
}
|
||||
|
||||
function assertValidTaskId(taskId: string): void {
|
||||
const validationError = validateTaskId(taskId)
|
||||
if (validationError) {
|
||||
throw new Error(validationError)
|
||||
}
|
||||
}
|
||||
|
||||
function writeTaskToPath(path: string, task: TeamTask): void {
|
||||
writeJsonAtomic(path, TeamTaskSchema.parse(task))
|
||||
}
|
||||
|
||||
export function updateTeamTask(teamName: string, taskId: string, patch: TeamTaskUpdatePatch): TeamTask {
|
||||
assertValidTeamName(teamName)
|
||||
assertValidTaskId(taskId)
|
||||
|
||||
if (patch.addBlocks) {
|
||||
for (const blockedTaskId of patch.addBlocks) {
|
||||
assertValidTaskId(blockedTaskId)
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.addBlockedBy) {
|
||||
for (const blockerId of patch.addBlockedBy) {
|
||||
assertValidTaskId(blockerId)
|
||||
}
|
||||
}
|
||||
|
||||
return withTeamTaskLock(teamName, () => {
|
||||
const taskDir = getTeamTaskDir(teamName)
|
||||
const taskPath = getTeamTaskPath(teamName, taskId)
|
||||
const currentTask = readJsonSafe(taskPath, TeamTaskSchema)
|
||||
if (!currentTask) {
|
||||
throw new Error("team_task_not_found")
|
||||
}
|
||||
|
||||
const cache = new Map<string, TeamTask | null>()
|
||||
cache.set(taskId, currentTask)
|
||||
|
||||
const readTask = (id: string): TeamTask | null => {
|
||||
if (cache.has(id)) {
|
||||
return cache.get(id) ?? null
|
||||
}
|
||||
const loaded = readJsonSafe(join(taskDir, `${id}.json`), TeamTaskSchema)
|
||||
cache.set(id, loaded)
|
||||
return loaded
|
||||
}
|
||||
|
||||
const pendingEdges = createPendingEdgeMap()
|
||||
|
||||
if (patch.addBlocks) {
|
||||
for (const blockedTaskId of patch.addBlocks) {
|
||||
if (blockedTaskId === taskId) {
|
||||
throw new Error("team_task_self_block")
|
||||
}
|
||||
if (!readTask(blockedTaskId)) {
|
||||
throw new Error(`team_task_reference_not_found:${blockedTaskId}`)
|
||||
}
|
||||
addPendingEdge(pendingEdges, blockedTaskId, taskId)
|
||||
}
|
||||
|
||||
for (const blockedTaskId of patch.addBlocks) {
|
||||
if (wouldCreateCycle(blockedTaskId, taskId, pendingEdges, readTask)) {
|
||||
throw new Error(`team_task_cycle_detected:${taskId}->${blockedTaskId}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.addBlockedBy) {
|
||||
for (const blockerId of patch.addBlockedBy) {
|
||||
if (blockerId === taskId) {
|
||||
throw new Error("team_task_self_dependency")
|
||||
}
|
||||
if (!readTask(blockerId)) {
|
||||
throw new Error(`team_task_reference_not_found:${blockerId}`)
|
||||
}
|
||||
addPendingEdge(pendingEdges, taskId, blockerId)
|
||||
}
|
||||
|
||||
for (const blockerId of patch.addBlockedBy) {
|
||||
if (wouldCreateCycle(taskId, blockerId, pendingEdges, readTask)) {
|
||||
throw new Error(`team_task_cycle_detected:${taskId}<-${blockerId}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.status && patch.status !== "deleted") {
|
||||
ensureForwardStatusTransition(currentTask.status, patch.status)
|
||||
}
|
||||
|
||||
const effectiveStatus = patch.status ?? currentTask.status
|
||||
const effectiveBlockedBy = Array.from(new Set([...(currentTask.blockedBy ?? []), ...(patch.addBlockedBy ?? [])]))
|
||||
const shouldValidateDependencies =
|
||||
(patch.status !== undefined || (patch.addBlockedBy?.length ?? 0) > 0) && effectiveStatus !== "deleted"
|
||||
|
||||
if (shouldValidateDependencies) {
|
||||
ensureDependenciesCompleted(effectiveStatus, effectiveBlockedBy, readTask)
|
||||
}
|
||||
|
||||
let nextTask: TeamTask = { ...currentTask }
|
||||
|
||||
if (patch.subject !== undefined) {
|
||||
nextTask.subject = patch.subject
|
||||
}
|
||||
if (patch.description !== undefined) {
|
||||
nextTask.description = patch.description
|
||||
}
|
||||
if (patch.activeForm !== undefined) {
|
||||
nextTask.activeForm = patch.activeForm
|
||||
}
|
||||
if (patch.owner !== undefined) {
|
||||
nextTask.owner = patch.owner === "" ? undefined : patch.owner
|
||||
}
|
||||
|
||||
const pendingWrites = new Map<string, TeamTask>()
|
||||
|
||||
if (patch.addBlocks) {
|
||||
const existingBlocks = new Set(nextTask.blocks)
|
||||
for (const blockedTaskId of patch.addBlocks) {
|
||||
if (!existingBlocks.has(blockedTaskId)) {
|
||||
nextTask.blocks.push(blockedTaskId)
|
||||
existingBlocks.add(blockedTaskId)
|
||||
}
|
||||
|
||||
const otherPath = getTeamTaskPath(teamName, blockedTaskId)
|
||||
const other = pendingWrites.get(otherPath) ?? readTask(blockedTaskId)
|
||||
if (other && !other.blockedBy.includes(taskId)) {
|
||||
pendingWrites.set(otherPath, { ...other, blockedBy: [...other.blockedBy, taskId] })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.addBlockedBy) {
|
||||
const existingBlockedBy = new Set(nextTask.blockedBy)
|
||||
for (const blockerId of patch.addBlockedBy) {
|
||||
if (!existingBlockedBy.has(blockerId)) {
|
||||
nextTask.blockedBy.push(blockerId)
|
||||
existingBlockedBy.add(blockerId)
|
||||
}
|
||||
|
||||
const otherPath = getTeamTaskPath(teamName, blockerId)
|
||||
const other = pendingWrites.get(otherPath) ?? readTask(blockerId)
|
||||
if (other && !other.blocks.includes(taskId)) {
|
||||
pendingWrites.set(otherPath, { ...other, blocks: [...other.blocks, taskId] })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.metadata !== undefined) {
|
||||
const merged: Record<string, unknown> = { ...(nextTask.metadata ?? {}) }
|
||||
for (const [key, value] of Object.entries(patch.metadata)) {
|
||||
if (value === null) {
|
||||
delete merged[key]
|
||||
} else {
|
||||
merged[key] = value
|
||||
}
|
||||
}
|
||||
nextTask.metadata = Object.keys(merged).length > 0 ? merged : undefined
|
||||
}
|
||||
|
||||
if (patch.status !== undefined) {
|
||||
nextTask.status = patch.status
|
||||
}
|
||||
|
||||
const allTaskFiles = readdirSync(taskDir).filter((file) => file.endsWith(".json") && file.startsWith("T-"))
|
||||
|
||||
if (nextTask.status === "completed") {
|
||||
for (const file of allTaskFiles) {
|
||||
const otherId = file.replace(/\.json$/, "")
|
||||
if (otherId === taskId) continue
|
||||
|
||||
const otherPath = getTeamTaskPath(teamName, otherId)
|
||||
const other = pendingWrites.get(otherPath) ?? readTask(otherId)
|
||||
if (other?.blockedBy.includes(taskId)) {
|
||||
pendingWrites.set(otherPath, {
|
||||
...other,
|
||||
blockedBy: other.blockedBy.filter((id) => id !== taskId),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.status === "deleted") {
|
||||
for (const file of allTaskFiles) {
|
||||
const otherId = file.replace(/\.json$/, "")
|
||||
if (otherId === taskId) continue
|
||||
|
||||
const otherPath = getTeamTaskPath(teamName, otherId)
|
||||
const other = pendingWrites.get(otherPath) ?? readTask(otherId)
|
||||
if (!other) continue
|
||||
|
||||
const nextOther = {
|
||||
...other,
|
||||
blockedBy: other.blockedBy.filter((id) => id !== taskId),
|
||||
blocks: other.blocks.filter((id) => id !== taskId),
|
||||
}
|
||||
pendingWrites.set(otherPath, nextOther)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [path, task] of pendingWrites.entries()) {
|
||||
writeTaskToPath(path, task)
|
||||
}
|
||||
|
||||
if (patch.status === "deleted") {
|
||||
if (existsSync(taskPath)) {
|
||||
unlinkSync(taskPath)
|
||||
}
|
||||
return TeamTaskSchema.parse({ ...nextTask, status: "deleted" })
|
||||
}
|
||||
|
||||
writeTaskToPath(taskPath, nextTask)
|
||||
return TeamTaskSchema.parse(nextTask)
|
||||
})
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { afterEach, beforeEach, describe, expect, it } 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 { createForceKillTeammateTool, createProcessShutdownApprovedTool } from "./teammate-control-tools"
|
||||
import { readTeamConfig } from "./team-config-store"
|
||||
import { upsertTeammate, writeTeamConfig } from "./team-config-store"
|
||||
import { ensureInbox } from "./inbox-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: any,
|
||||
args: Record<string, unknown>,
|
||||
context: TestToolContext,
|
||||
): Promise<unknown> {
|
||||
const output = await tool.execute(args, context)
|
||||
return JSON.parse(output)
|
||||
}
|
||||
|
||||
describe("teammate-control-tools", () => {
|
||||
let originalCwd: string
|
||||
let tempProjectDir: string
|
||||
const teamName = `test-team-control-${TEST_SUFFIX}`
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd()
|
||||
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-control-"))
|
||||
process.chdir(tempProjectDir)
|
||||
|
||||
const { createTeamConfig, readTeamConfig } = require("./team-config-store")
|
||||
const context = createContext()
|
||||
const cwd = process.cwd()
|
||||
|
||||
if (!readTeamConfig(teamName)) {
|
||||
createTeamConfig(
|
||||
teamName,
|
||||
"Test team",
|
||||
context.sessionID,
|
||||
cwd,
|
||||
"native/team-lead",
|
||||
)
|
||||
}
|
||||
|
||||
ensureInbox(teamName, "team-lead")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd)
|
||||
if (existsSync(tempProjectDir)) {
|
||||
rmSync(tempProjectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe("createForceKillTeammateTool", () => {
|
||||
it("returns error when team not found", async () => {
|
||||
const tool = createForceKillTeammateTool()
|
||||
const testContext = createContext()
|
||||
|
||||
const result = await executeJsonTool(
|
||||
tool,
|
||||
{ team_name: "nonexistent-team", teammate_name: "test-teammate" },
|
||||
testContext,
|
||||
)
|
||||
|
||||
expect(result).toHaveProperty("error")
|
||||
})
|
||||
|
||||
it("returns error when trying to remove team-lead", async () => {
|
||||
const tool = createForceKillTeammateTool()
|
||||
const testContext = createContext()
|
||||
|
||||
const result = await executeJsonTool(
|
||||
tool,
|
||||
{ team_name: teamName, teammate_name: "team-lead" },
|
||||
testContext,
|
||||
)
|
||||
|
||||
expect(result).toHaveProperty("error", "cannot_remove_team_lead")
|
||||
})
|
||||
|
||||
it("returns error when teammate does not exist", async () => {
|
||||
const tool = createForceKillTeammateTool()
|
||||
const testContext = createContext()
|
||||
|
||||
const result = await executeJsonTool(
|
||||
tool,
|
||||
{ team_name: teamName, teammate_name: "nonexistent-teammate" },
|
||||
testContext,
|
||||
)
|
||||
|
||||
expect(result).toHaveProperty("error", "teammate_not_found")
|
||||
})
|
||||
|
||||
it("removes teammate from config and deletes inbox", async () => {
|
||||
const config = readTeamConfig(teamName)!
|
||||
const currentCwd = process.cwd()
|
||||
const teammate = {
|
||||
agentId: `test-teammate-${TEST_SUFFIX}@${teamName}`,
|
||||
name: `test-teammate-${TEST_SUFFIX}`,
|
||||
agentType: "teammate" as const,
|
||||
category: "quick",
|
||||
model: "gpt-5-mini",
|
||||
prompt: "Test prompt",
|
||||
planModeRequired: false,
|
||||
joinedAt: new Date().toISOString(),
|
||||
cwd: currentCwd,
|
||||
subscriptions: [],
|
||||
backendType: "native" as const,
|
||||
isActive: true,
|
||||
sessionID: `ses_teammate-${TEST_SUFFIX}`,
|
||||
backgroundTaskID: undefined,
|
||||
color: "#FF6B6B",
|
||||
}
|
||||
const updatedConfig = upsertTeammate(config, teammate)
|
||||
writeTeamConfig(teamName, updatedConfig)
|
||||
|
||||
ensureInbox(teamName, `test-teammate-${TEST_SUFFIX}`)
|
||||
|
||||
const tool = createForceKillTeammateTool()
|
||||
const testContext = createContext()
|
||||
|
||||
const result = await executeJsonTool(
|
||||
tool,
|
||||
{ team_name: teamName, teammate_name: `test-teammate-${TEST_SUFFIX}` },
|
||||
testContext,
|
||||
)
|
||||
|
||||
expect(result).toHaveProperty("killed", true)
|
||||
expect(result).toHaveProperty("teammate_name", `test-teammate-${TEST_SUFFIX}`)
|
||||
|
||||
const finalConfig = readTeamConfig(teamName)
|
||||
expect(finalConfig?.members.some((m) => m.name === `test-teammate-${TEST_SUFFIX}`)).toBe(false)
|
||||
|
||||
const inboxPath = `.sisyphus/teams/${teamName}/inbox/test-teammate-${TEST_SUFFIX}.json`
|
||||
expect(existsSync(inboxPath)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createProcessShutdownApprovedTool", () => {
|
||||
it("returns error when team not found", async () => {
|
||||
const tool = createProcessShutdownApprovedTool()
|
||||
const testContext = createContext()
|
||||
|
||||
const result = await executeJsonTool(
|
||||
tool,
|
||||
{ team_name: "nonexistent-team", teammate_name: "test-teammate" },
|
||||
testContext,
|
||||
)
|
||||
|
||||
expect(result).toHaveProperty("error")
|
||||
})
|
||||
|
||||
it("returns error when trying to remove team-lead", async () => {
|
||||
const tool = createProcessShutdownApprovedTool()
|
||||
const testContext = createContext()
|
||||
|
||||
const result = await executeJsonTool(
|
||||
tool,
|
||||
{ team_name: teamName, teammate_name: "team-lead" },
|
||||
testContext,
|
||||
)
|
||||
|
||||
expect(result).toHaveProperty("error", "cannot_remove_team_lead")
|
||||
})
|
||||
|
||||
it("returns error when teammate does not exist", async () => {
|
||||
const tool = createProcessShutdownApprovedTool()
|
||||
const testContext = createContext()
|
||||
|
||||
const result = await executeJsonTool(
|
||||
tool,
|
||||
{ team_name: teamName, teammate_name: "nonexistent-teammate" },
|
||||
testContext,
|
||||
)
|
||||
|
||||
expect(result).toHaveProperty("error", "teammate_not_found")
|
||||
})
|
||||
|
||||
it("removes teammate from config and deletes inbox gracefully", async () => {
|
||||
const config = readTeamConfig(teamName)!
|
||||
const currentCwd = process.cwd()
|
||||
const teammateName = `test-teammate2-${TEST_SUFFIX}`
|
||||
const teammate = {
|
||||
agentId: `${teammateName}@${teamName}`,
|
||||
name: teammateName,
|
||||
agentType: "teammate" as const,
|
||||
category: "quick",
|
||||
model: "gpt-5-mini",
|
||||
prompt: "Test prompt",
|
||||
planModeRequired: false,
|
||||
joinedAt: new Date().toISOString(),
|
||||
cwd: currentCwd,
|
||||
subscriptions: [],
|
||||
backendType: "native" as const,
|
||||
isActive: true,
|
||||
sessionID: `ses_${teammateName}`,
|
||||
backgroundTaskID: undefined,
|
||||
color: "#4ECDC4",
|
||||
}
|
||||
const updatedConfig = upsertTeammate(config, teammate)
|
||||
writeTeamConfig(teamName, updatedConfig)
|
||||
|
||||
ensureInbox(teamName, teammateName)
|
||||
|
||||
const tool = createProcessShutdownApprovedTool()
|
||||
const testContext = createContext()
|
||||
|
||||
const result = await executeJsonTool(
|
||||
tool,
|
||||
{ team_name: teamName, teammate_name: teammateName },
|
||||
testContext,
|
||||
)
|
||||
|
||||
expect(result).toHaveProperty("shutdown_processed", true)
|
||||
expect(result).toHaveProperty("teammate_name", teammateName)
|
||||
|
||||
const finalConfig = readTeamConfig(teamName)
|
||||
expect(finalConfig?.members.some((m) => m.name === teammateName)).toBe(false)
|
||||
|
||||
const inboxPath = `.sisyphus/teams/${teamName}/inbox/${teammateName}.json`
|
||||
expect(existsSync(inboxPath)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,103 +0,0 @@
|
||||
import { tool } from "@opencode-ai/plugin/tool"
|
||||
import { ForceKillTeammateInputSchema, ProcessShutdownApprovedInputSchema, isTeammateMember } from "./types"
|
||||
import { readTeamConfig, removeTeammate, updateTeamConfig, getTeamMember } from "./team-config-store"
|
||||
import { deleteInbox } from "./inbox-store"
|
||||
|
||||
export function createForceKillTeammateTool() {
|
||||
return tool({
|
||||
description: "Force kill a teammate - remove from team config and delete inbox without graceful shutdown.",
|
||||
args: {
|
||||
team_name: tool.schema.string().describe("Team name"),
|
||||
teammate_name: tool.schema.string().describe("Teammate name to kill"),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>): Promise<string> => {
|
||||
try {
|
||||
const input = ForceKillTeammateInputSchema.parse(args)
|
||||
|
||||
const config = readTeamConfig(input.team_name)
|
||||
if (!config) {
|
||||
return JSON.stringify({ error: "team_not_found" })
|
||||
}
|
||||
|
||||
const teammate = getTeamMember(config, input.teammate_name)
|
||||
if (!teammate) {
|
||||
return JSON.stringify({ error: "teammate_not_found" })
|
||||
}
|
||||
|
||||
if (input.teammate_name === "team-lead") {
|
||||
return JSON.stringify({ error: "cannot_remove_team_lead" })
|
||||
}
|
||||
|
||||
if (!isTeammateMember(teammate)) {
|
||||
return JSON.stringify({ error: "not_a_teammate" })
|
||||
}
|
||||
|
||||
updateTeamConfig(input.team_name, (config) => removeTeammate(config, input.teammate_name))
|
||||
deleteInbox(input.team_name, input.teammate_name)
|
||||
|
||||
return JSON.stringify({
|
||||
killed: true,
|
||||
teammate_name: input.teammate_name,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === "cannot_remove_team_lead") {
|
||||
return JSON.stringify({ error: "cannot_remove_team_lead" })
|
||||
}
|
||||
return JSON.stringify({ error: error.message })
|
||||
}
|
||||
return JSON.stringify({ error: "force_kill_failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createProcessShutdownApprovedTool() {
|
||||
return tool({
|
||||
description:
|
||||
"Process approved teammate shutdown - remove from team config and delete inbox gracefully.",
|
||||
args: {
|
||||
team_name: tool.schema.string().describe("Team name"),
|
||||
teammate_name: tool.schema.string().describe("Teammate name to shutdown"),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>): Promise<string> => {
|
||||
try {
|
||||
const input = ProcessShutdownApprovedInputSchema.parse(args)
|
||||
|
||||
const config = readTeamConfig(input.team_name)
|
||||
if (!config) {
|
||||
return JSON.stringify({ error: "team_not_found" })
|
||||
}
|
||||
|
||||
const teammate = getTeamMember(config, input.teammate_name)
|
||||
if (!teammate) {
|
||||
return JSON.stringify({ error: "teammate_not_found" })
|
||||
}
|
||||
|
||||
if (input.teammate_name === "team-lead") {
|
||||
return JSON.stringify({ error: "cannot_remove_team_lead" })
|
||||
}
|
||||
|
||||
if (!isTeammateMember(teammate)) {
|
||||
return JSON.stringify({ error: "not_a_teammate" })
|
||||
}
|
||||
|
||||
updateTeamConfig(input.team_name, (config) => removeTeammate(config, input.teammate_name))
|
||||
deleteInbox(input.team_name, input.teammate_name)
|
||||
|
||||
return JSON.stringify({
|
||||
shutdown_processed: true,
|
||||
teammate_name: input.teammate_name,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === "cannot_remove_team_lead") {
|
||||
return JSON.stringify({ error: "cannot_remove_team_lead" })
|
||||
}
|
||||
return JSON.stringify({ error: error.message })
|
||||
}
|
||||
return JSON.stringify({ error: "shutdown_processing_failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { buildTeamParentToolContext } from "./teammate-parent-context"
|
||||
|
||||
describe("agent-teams teammate parent context", () => {
|
||||
test("forwards incoming abort signal to parent context resolver", () => {
|
||||
//#given
|
||||
const abortSignal = new AbortController().signal
|
||||
|
||||
//#when
|
||||
const parentToolContext = buildTeamParentToolContext({
|
||||
sessionID: "ses-main",
|
||||
messageID: "msg-main",
|
||||
agent: "sisyphus",
|
||||
abort: abortSignal,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(parentToolContext.abort).toBe(abortSignal)
|
||||
expect(parentToolContext.sessionID).toBe("ses-main")
|
||||
expect(parentToolContext.messageID).toBe("msg-main")
|
||||
expect(parentToolContext.agent).toBe("sisyphus")
|
||||
})
|
||||
|
||||
test("leaves agent undefined if missing in tool context", () => {
|
||||
//#when
|
||||
const parentToolContext = buildTeamParentToolContext({
|
||||
sessionID: "ses-main",
|
||||
messageID: "msg-main",
|
||||
abort: new AbortController().signal,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(parentToolContext.agent).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { ParentContext } from "../delegate-task/executor"
|
||||
import { resolveParentContext } from "../delegate-task/executor"
|
||||
import type { ToolContextWithMetadata } from "../delegate-task/types"
|
||||
import type { TeamToolContext } from "./types"
|
||||
|
||||
export function buildTeamParentToolContext(context: TeamToolContext): ToolContextWithMetadata {
|
||||
return {
|
||||
sessionID: context.sessionID,
|
||||
messageID: context.messageID,
|
||||
agent: context.agent,
|
||||
abort: context.abort ?? new AbortController().signal,
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveTeamParentContext(context: TeamToolContext): ParentContext {
|
||||
return resolveParentContext(buildTeamParentToolContext(context))
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
export function buildLaunchPrompt(
|
||||
teamName: string,
|
||||
teammateName: string,
|
||||
userPrompt: string,
|
||||
categoryPromptAppend?: string,
|
||||
): string {
|
||||
const sections = [
|
||||
`You are teammate "${teammateName}" in team "${teamName}".`,
|
||||
`When you need updates, call read_inbox with team_name="${teamName}" and agent_name="${teammateName}".`,
|
||||
"Initial assignment:",
|
||||
userPrompt,
|
||||
]
|
||||
|
||||
if (categoryPromptAppend) {
|
||||
sections.push("Category guidance:", categoryPromptAppend)
|
||||
}
|
||||
|
||||
return sections.join("\n\n")
|
||||
}
|
||||
|
||||
export function buildDeliveryPrompt(teamName: string, teammateName: string, summary: string, content: string): string {
|
||||
return [
|
||||
`New team message for "${teammateName}" in team "${teamName}".`,
|
||||
`Summary: ${summary}`,
|
||||
"Content:",
|
||||
content,
|
||||
].join("\n\n")
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import { clearInbox, ensureInbox, sendPlainInboxMessage } from "./inbox-store"
|
||||
import { assignNextColor, getTeamMember, removeTeammate, updateTeamConfig, upsertTeammate } from "./team-config-store"
|
||||
import type { TeamTeammateMember, TeamToolContext } from "./types"
|
||||
import { resolveTeamParentContext } from "./teammate-parent-context"
|
||||
import { buildDeliveryPrompt, buildLaunchPrompt } from "./teammate-prompts"
|
||||
import { resolveSpawnExecution, type TeamCategoryContext } from "./teammate-spawn-execution"
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function resolveLaunchFailureMessage(status: string | undefined, error: string | undefined): string {
|
||||
if (status === "error") {
|
||||
return error ? `teammate_launch_failed:${error}` : "teammate_launch_failed"
|
||||
}
|
||||
|
||||
if (status === "cancelled") {
|
||||
return "teammate_launch_cancelled"
|
||||
}
|
||||
|
||||
return "teammate_launch_timeout"
|
||||
}
|
||||
|
||||
export interface SpawnTeammateParams {
|
||||
teamName: string
|
||||
name: string
|
||||
prompt: string
|
||||
category: string
|
||||
subagentType: string
|
||||
model?: string
|
||||
planModeRequired: boolean
|
||||
context: TeamToolContext
|
||||
manager: BackgroundManager
|
||||
categoryContext?: TeamCategoryContext
|
||||
}
|
||||
|
||||
export async function spawnTeammate(params: SpawnTeammateParams): Promise<TeamTeammateMember> {
|
||||
const parentContext = resolveTeamParentContext(params.context)
|
||||
const execution = await resolveSpawnExecution(
|
||||
{
|
||||
teamName: params.teamName,
|
||||
name: params.name,
|
||||
prompt: params.prompt,
|
||||
category: params.category,
|
||||
subagentType: params.subagentType,
|
||||
model: params.model,
|
||||
manager: params.manager,
|
||||
categoryContext: params.categoryContext,
|
||||
},
|
||||
parentContext,
|
||||
)
|
||||
|
||||
let teammate: TeamTeammateMember | undefined
|
||||
let launchedTaskID: string | undefined
|
||||
|
||||
updateTeamConfig(params.teamName, (current) => {
|
||||
if (getTeamMember(current, params.name)) {
|
||||
throw new Error("teammate_already_exists")
|
||||
}
|
||||
|
||||
teammate = {
|
||||
agentId: `${params.name}@${params.teamName}`,
|
||||
name: params.name,
|
||||
agentType: "teammate",
|
||||
category: params.category,
|
||||
model: execution.teammateModel,
|
||||
prompt: params.prompt,
|
||||
color: assignNextColor(current),
|
||||
planModeRequired: params.planModeRequired,
|
||||
joinedAt: new Date().toISOString(),
|
||||
cwd: process.cwd(),
|
||||
subscriptions: [],
|
||||
backendType: "native",
|
||||
isActive: false,
|
||||
}
|
||||
|
||||
return upsertTeammate(current, teammate)
|
||||
})
|
||||
|
||||
if (!teammate) {
|
||||
throw new Error("teammate_create_failed")
|
||||
}
|
||||
|
||||
try {
|
||||
ensureInbox(params.teamName, params.name)
|
||||
sendPlainInboxMessage(params.teamName, "team-lead", params.name, params.prompt, "initial_prompt", teammate.color)
|
||||
|
||||
const launched = await params.manager.launch({
|
||||
description: `[team:${params.teamName}] ${params.name}`,
|
||||
prompt: buildLaunchPrompt(params.teamName, params.name, params.prompt, execution.categoryPromptAppend),
|
||||
agent: execution.agentType,
|
||||
parentSessionID: parentContext.sessionID,
|
||||
parentMessageID: parentContext.messageID,
|
||||
parentModel: parentContext.model,
|
||||
...(execution.launchModel ? { model: execution.launchModel } : {}),
|
||||
...(params.category ? { category: params.category } : {}),
|
||||
parentAgent: parentContext.agent,
|
||||
})
|
||||
launchedTaskID = launched.id
|
||||
|
||||
const start = Date.now()
|
||||
let sessionID = launched.sessionID
|
||||
let latestStatus: string | undefined
|
||||
let latestError: string | undefined
|
||||
while (!sessionID && Date.now() - start < 30_000) {
|
||||
await delay(50)
|
||||
const task = params.manager.getTask(launched.id)
|
||||
latestStatus = task?.status
|
||||
latestError = task?.error
|
||||
if (task?.status === "error" || task?.status === "cancelled") {
|
||||
throw new Error(resolveLaunchFailureMessage(task.status, task.error))
|
||||
}
|
||||
sessionID = task?.sessionID
|
||||
}
|
||||
|
||||
if (!sessionID) {
|
||||
throw new Error(resolveLaunchFailureMessage(latestStatus, latestError))
|
||||
}
|
||||
|
||||
const nextMember: TeamTeammateMember = {
|
||||
...teammate,
|
||||
isActive: true,
|
||||
backgroundTaskID: launched.id,
|
||||
sessionID,
|
||||
}
|
||||
|
||||
updateTeamConfig(params.teamName, (current) => upsertTeammate(current, nextMember))
|
||||
return nextMember
|
||||
} catch (error) {
|
||||
const originalError = error
|
||||
|
||||
if (launchedTaskID) {
|
||||
await params.manager
|
||||
.cancelTask(launchedTaskID, {
|
||||
source: "team_launch_failed",
|
||||
abortSession: true,
|
||||
skipNotification: true,
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
try {
|
||||
updateTeamConfig(params.teamName, (current) => removeTeammate(current, params.name))
|
||||
} catch (cleanupError) {
|
||||
void cleanupError
|
||||
}
|
||||
|
||||
try {
|
||||
clearInbox(params.teamName, params.name)
|
||||
} catch (cleanupError) {
|
||||
void cleanupError
|
||||
}
|
||||
|
||||
throw originalError
|
||||
}
|
||||
}
|
||||
|
||||
export async function resumeTeammateWithMessage(
|
||||
manager: BackgroundManager,
|
||||
context: TeamToolContext,
|
||||
teamName: string,
|
||||
teammate: TeamTeammateMember,
|
||||
summary: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
if (!teammate.sessionID) {
|
||||
return
|
||||
}
|
||||
|
||||
const parentContext = resolveTeamParentContext(context)
|
||||
|
||||
try {
|
||||
await manager.resume({
|
||||
sessionId: teammate.sessionID,
|
||||
prompt: buildDeliveryPrompt(teamName, teammate.name, summary, content),
|
||||
parentSessionID: parentContext.sessionID,
|
||||
parentMessageID: parentContext.messageID,
|
||||
parentModel: parentContext.model,
|
||||
parentAgent: parentContext.agent,
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelTeammateRun(manager: BackgroundManager, teammate: TeamTeammateMember): Promise<void> {
|
||||
if (!teammate.backgroundTaskID) {
|
||||
return
|
||||
}
|
||||
|
||||
await manager.cancelTask(teammate.backgroundTaskID, {
|
||||
source: "team_force_kill",
|
||||
abortSession: true,
|
||||
skipNotification: true,
|
||||
})
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { CategoriesConfig } from "../../config/schema"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import type { ParentContext } from "../delegate-task/executor"
|
||||
import { resolveCategoryExecution } from "../delegate-task/executor"
|
||||
import type { DelegateTaskArgs } from "../delegate-task/types"
|
||||
|
||||
function parseModel(model: string | undefined): { providerID: string; modelID: string } | undefined {
|
||||
if (!model) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const separatorIndex = model.indexOf("/")
|
||||
if (separatorIndex <= 0 || separatorIndex >= model.length - 1) {
|
||||
throw new Error("invalid_model_override_format")
|
||||
}
|
||||
|
||||
return {
|
||||
providerID: model.slice(0, separatorIndex),
|
||||
modelID: model.slice(separatorIndex + 1),
|
||||
}
|
||||
}
|
||||
|
||||
async function getSystemDefaultModel(client: PluginInput["client"]): Promise<string | undefined> {
|
||||
try {
|
||||
const openCodeConfig = await client.config.get()
|
||||
return (openCodeConfig as { data?: { model?: string } })?.data?.model
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export interface TeamCategoryContext {
|
||||
client: PluginInput["client"]
|
||||
userCategories?: CategoriesConfig
|
||||
sisyphusJuniorModel?: string
|
||||
}
|
||||
|
||||
export interface SpawnExecutionRequest {
|
||||
teamName: string
|
||||
name: string
|
||||
prompt: string
|
||||
category: string
|
||||
subagentType: string
|
||||
model?: string
|
||||
manager: BackgroundManager
|
||||
categoryContext?: TeamCategoryContext
|
||||
}
|
||||
|
||||
export interface SpawnExecutionResult {
|
||||
agentType: string
|
||||
teammateModel: string
|
||||
launchModel?: { providerID: string; modelID: string; variant?: string }
|
||||
categoryPromptAppend?: string
|
||||
}
|
||||
|
||||
export async function resolveSpawnExecution(
|
||||
request: SpawnExecutionRequest,
|
||||
parentContext: ParentContext,
|
||||
): Promise<SpawnExecutionResult> {
|
||||
if (request.model) {
|
||||
const launchModel = parseModel(request.model)
|
||||
return {
|
||||
agentType: request.subagentType,
|
||||
teammateModel: request.model,
|
||||
...(launchModel ? { launchModel } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
if (!request.categoryContext?.client) {
|
||||
return {
|
||||
agentType: request.subagentType,
|
||||
teammateModel: "native",
|
||||
}
|
||||
}
|
||||
|
||||
const inheritedModel = parentContext.model
|
||||
? `${parentContext.model.providerID}/${parentContext.model.modelID}`
|
||||
: undefined
|
||||
|
||||
const systemDefaultModel = await getSystemDefaultModel(request.categoryContext.client)
|
||||
|
||||
const delegateArgs: DelegateTaskArgs = {
|
||||
description: `[team:${request.teamName}] ${request.name}`,
|
||||
prompt: request.prompt,
|
||||
category: request.category,
|
||||
subagent_type: "sisyphus-junior",
|
||||
run_in_background: true,
|
||||
load_skills: [],
|
||||
}
|
||||
|
||||
const resolution = await resolveCategoryExecution(
|
||||
delegateArgs,
|
||||
{
|
||||
manager: request.manager,
|
||||
client: request.categoryContext.client,
|
||||
directory: process.cwd(),
|
||||
userCategories: request.categoryContext.userCategories,
|
||||
sisyphusJuniorModel: request.categoryContext.sisyphusJuniorModel,
|
||||
},
|
||||
inheritedModel,
|
||||
systemDefaultModel,
|
||||
)
|
||||
|
||||
if (resolution.error) {
|
||||
throw new Error(resolution.error)
|
||||
}
|
||||
|
||||
if (!resolution.categoryModel) {
|
||||
throw new Error("category_model_not_resolved")
|
||||
}
|
||||
|
||||
return {
|
||||
agentType: resolution.agentToUse,
|
||||
teammateModel: `${resolution.categoryModel.providerID}/${resolution.categoryModel.modelID}`,
|
||||
launchModel: resolution.categoryModel,
|
||||
categoryPromptAppend: resolution.categoryPromptAppend,
|
||||
}
|
||||
}
|
||||
@@ -1,97 +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 TestToolContext {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
agent: string
|
||||
abort: AbortSignal
|
||||
}
|
||||
|
||||
interface MockManagerHandles {
|
||||
manager: BackgroundManager
|
||||
launchCalls: Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
function createMockManager(): MockManagerHandles {
|
||||
const launchCalls: Array<Record<string, unknown>> = []
|
||||
|
||||
const manager = {
|
||||
launch: async (args: Record<string, unknown>) => {
|
||||
launchCalls.push(args)
|
||||
return { id: `bg-${launchCalls.length}`, sessionID: `ses-worker-${launchCalls.length}` }
|
||||
},
|
||||
getTask: () => undefined,
|
||||
resume: async () => ({ id: "resume-1" }),
|
||||
cancelTask: async () => true,
|
||||
} as unknown as BackgroundManager
|
||||
|
||||
return { manager, launchCalls }
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
describe("agent-teams teammate tools", () => {
|
||||
let originalCwd: string
|
||||
let tempProjectDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd()
|
||||
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-teammate-tools-"))
|
||||
process.chdir(tempProjectDir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd)
|
||||
rmSync(tempProjectDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test("spawn_teammate requires lead session authorization", async () => {
|
||||
//#given
|
||||
const { manager, launchCalls } = createMockManager()
|
||||
const tools = createAgentTeamsTools(manager)
|
||||
const leadContext = createContext("ses-lead")
|
||||
const teammateContext = createContext("ses-worker")
|
||||
|
||||
await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext)
|
||||
|
||||
//#when
|
||||
const unauthorized = await executeJsonTool(
|
||||
tools,
|
||||
"spawn_teammate",
|
||||
{
|
||||
team_name: "core",
|
||||
name: "worker_1",
|
||||
prompt: "Handle release prep",
|
||||
category: "quick",
|
||||
},
|
||||
teammateContext,
|
||||
) as { error?: string }
|
||||
|
||||
//#then
|
||||
expect(unauthorized.error).toBe("unauthorized_lead_session")
|
||||
expect(launchCalls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -1,198 +0,0 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { CategoriesConfig } from "../../config/schema"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import { clearInbox } from "./inbox-store"
|
||||
import { validateAgentName, validateTeamName } from "./name-validation"
|
||||
import {
|
||||
TeamForceKillInputSchema,
|
||||
TeamProcessShutdownInputSchema,
|
||||
TeamSpawnInputSchema,
|
||||
TeamToolContext,
|
||||
isTeammateMember,
|
||||
} from "./types"
|
||||
import { getTeamMember, readTeamConfigOrThrow, removeTeammate, updateTeamConfig } from "./team-config-store"
|
||||
import { cancelTeammateRun, spawnTeammate } from "./teammate-runtime"
|
||||
import { resetOwnerTasks } from "./team-task-store"
|
||||
|
||||
export interface AgentTeamsSpawnOptions {
|
||||
client?: PluginInput["client"]
|
||||
userCategories?: CategoriesConfig
|
||||
sisyphusJuniorModel?: string
|
||||
}
|
||||
|
||||
async function shutdownTeammateWithCleanup(
|
||||
manager: BackgroundManager,
|
||||
context: TeamToolContext,
|
||||
teamName: string,
|
||||
agentName: string,
|
||||
): Promise<string | null> {
|
||||
const config = readTeamConfigOrThrow(teamName)
|
||||
if (context.sessionID !== config.leadSessionId) {
|
||||
return "unauthorized_lead_session"
|
||||
}
|
||||
|
||||
const member = getTeamMember(config, agentName)
|
||||
if (!member || !isTeammateMember(member)) {
|
||||
return "teammate_not_found"
|
||||
}
|
||||
|
||||
await cancelTeammateRun(manager, member)
|
||||
let removed = false
|
||||
|
||||
updateTeamConfig(teamName, (current) => {
|
||||
const refreshedMember = getTeamMember(current, agentName)
|
||||
if (!refreshedMember || !isTeammateMember(refreshedMember)) {
|
||||
return current
|
||||
}
|
||||
removed = true
|
||||
return removeTeammate(current, agentName)
|
||||
})
|
||||
|
||||
if (removed) {
|
||||
clearInbox(teamName, agentName)
|
||||
}
|
||||
|
||||
resetOwnerTasks(teamName, agentName)
|
||||
return null
|
||||
}
|
||||
|
||||
export function createSpawnTeammateTool(manager: BackgroundManager, options?: AgentTeamsSpawnOptions): ToolDefinition {
|
||||
return tool({
|
||||
description: "Spawn a teammate using native internal agent execution.",
|
||||
args: {
|
||||
team_name: tool.schema.string().describe("Team name"),
|
||||
name: tool.schema.string().describe("Teammate name"),
|
||||
prompt: tool.schema.string().describe("Initial teammate prompt"),
|
||||
category: tool.schema.string().describe("Required category for teammate metadata and routing"),
|
||||
subagent_type: tool.schema.string().optional().describe("Agent name to run (default: sisyphus-junior)"),
|
||||
model: tool.schema.string().optional().describe("Optional model override in provider/model format"),
|
||||
plan_mode_required: tool.schema.boolean().optional().describe("Enable plan mode flag in teammate metadata"),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
|
||||
try {
|
||||
const input = TeamSpawnInputSchema.parse(args)
|
||||
const teamError = validateTeamName(input.team_name)
|
||||
if (teamError) {
|
||||
return JSON.stringify({ error: teamError })
|
||||
}
|
||||
|
||||
const agentError = validateAgentName(input.name)
|
||||
if (agentError) {
|
||||
return JSON.stringify({ error: agentError })
|
||||
}
|
||||
|
||||
if (!input.category.trim()) {
|
||||
return JSON.stringify({ error: "category_required" })
|
||||
}
|
||||
|
||||
if (input.subagent_type && input.subagent_type !== "sisyphus-junior") {
|
||||
return JSON.stringify({ error: "category_conflicts_with_subagent_type" })
|
||||
}
|
||||
|
||||
const config = readTeamConfigOrThrow(input.team_name)
|
||||
if (context.sessionID !== config.leadSessionId) {
|
||||
return JSON.stringify({ error: "unauthorized_lead_session" })
|
||||
}
|
||||
|
||||
const resolvedSubagentType = input.subagent_type ?? "sisyphus-junior"
|
||||
|
||||
const teammate = await spawnTeammate({
|
||||
teamName: input.team_name,
|
||||
name: input.name,
|
||||
prompt: input.prompt,
|
||||
category: input.category,
|
||||
subagentType: resolvedSubagentType,
|
||||
model: input.model,
|
||||
planModeRequired: input.plan_mode_required ?? false,
|
||||
context,
|
||||
manager,
|
||||
categoryContext: options?.client
|
||||
? {
|
||||
client: options.client,
|
||||
userCategories: options.userCategories,
|
||||
sisyphusJuniorModel: options.sisyphusJuniorModel,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
|
||||
return JSON.stringify({
|
||||
agent_id: teammate.agentId,
|
||||
name: teammate.name,
|
||||
team_name: input.team_name,
|
||||
session_id: teammate.sessionID,
|
||||
task_id: teammate.backgroundTaskID,
|
||||
})
|
||||
} catch (error) {
|
||||
return JSON.stringify({ error: error instanceof Error ? error.message : "spawn_teammate_failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createForceKillTeammateTool(manager: BackgroundManager): ToolDefinition {
|
||||
return tool({
|
||||
description: "Force stop a teammate and clean up ownership state.",
|
||||
args: {
|
||||
team_name: tool.schema.string().describe("Team name"),
|
||||
teammate_name: tool.schema.string().describe("Teammate name"),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
|
||||
try {
|
||||
const input = TeamForceKillInputSchema.parse(args)
|
||||
const teamError = validateTeamName(input.team_name)
|
||||
if (teamError) {
|
||||
return JSON.stringify({ error: teamError })
|
||||
}
|
||||
const agentError = validateAgentName(input.teammate_name)
|
||||
if (agentError) {
|
||||
return JSON.stringify({ error: agentError })
|
||||
}
|
||||
|
||||
const shutdownError = await shutdownTeammateWithCleanup(manager, context, input.team_name, input.teammate_name)
|
||||
if (shutdownError) {
|
||||
return JSON.stringify({ error: shutdownError })
|
||||
}
|
||||
|
||||
return JSON.stringify({ success: true, message: `${input.teammate_name} stopped` })
|
||||
} catch (error) {
|
||||
return JSON.stringify({ error: error instanceof Error ? error.message : "force_kill_teammate_failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createProcessShutdownTool(manager: BackgroundManager): ToolDefinition {
|
||||
return tool({
|
||||
description: "Finalize an approved shutdown by removing teammate and resetting owned tasks.",
|
||||
args: {
|
||||
team_name: tool.schema.string().describe("Team name"),
|
||||
teammate_name: tool.schema.string().describe("Teammate name"),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
|
||||
try {
|
||||
const input = TeamProcessShutdownInputSchema.parse(args)
|
||||
const teamError = validateTeamName(input.team_name)
|
||||
if (teamError) {
|
||||
return JSON.stringify({ error: teamError })
|
||||
}
|
||||
if (input.teammate_name === "team-lead") {
|
||||
return JSON.stringify({ error: "cannot_shutdown_team_lead" })
|
||||
}
|
||||
const agentError = validateAgentName(input.teammate_name)
|
||||
if (agentError) {
|
||||
return JSON.stringify({ error: agentError })
|
||||
}
|
||||
|
||||
const shutdownError = await shutdownTeammateWithCleanup(manager, context, input.team_name, input.teammate_name)
|
||||
if (shutdownError) {
|
||||
return JSON.stringify({ error: shutdownError })
|
||||
}
|
||||
|
||||
return JSON.stringify({ success: true, message: `${input.teammate_name} removed` })
|
||||
} catch (error) {
|
||||
return JSON.stringify({ error: error instanceof Error ? error.message : "process_shutdown_failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,28 +0,0 @@
|
||||
import type { ToolDefinition } from "@opencode-ai/plugin"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { CategoriesConfig } from "../../config/schema"
|
||||
import { createReadInboxTool, createSendMessageTool } from "./messaging-tools"
|
||||
import { createTeamCreateTool, createTeamDeleteTool, createTeamReadConfigTool } from "./team-lifecycle-tools"
|
||||
import { createForceKillTeammateTool, createProcessShutdownApprovedTool } from "./teammate-control-tools"
|
||||
|
||||
export interface AgentTeamsToolOptions {
|
||||
client?: PluginInput["client"]
|
||||
userCategories?: CategoriesConfig
|
||||
sisyphusJuniorModel?: string
|
||||
}
|
||||
|
||||
export function createAgentTeamsTools(
|
||||
_manager: BackgroundManager,
|
||||
_options?: AgentTeamsToolOptions,
|
||||
): Record<string, ToolDefinition> {
|
||||
return {
|
||||
team_create: createTeamCreateTool(),
|
||||
team_delete: createTeamDeleteTool(),
|
||||
send_message: createSendMessageTool(_manager),
|
||||
read_inbox: createReadInboxTool(),
|
||||
read_config: createTeamReadConfigTool(),
|
||||
force_kill_teammate: createForceKillTeammateTool(),
|
||||
process_shutdown_approved: createProcessShutdownApprovedTool(),
|
||||
}
|
||||
}
|
||||
@@ -1,721 +0,0 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { z } from "zod"
|
||||
import {
|
||||
TeamConfigSchema,
|
||||
TeamMemberSchema,
|
||||
TeamTeammateMemberSchema,
|
||||
MessageTypeSchema,
|
||||
InboxMessageSchema,
|
||||
TeamTaskSchema,
|
||||
TeamCreateInputSchema,
|
||||
TeamDeleteInputSchema,
|
||||
SendMessageInputSchema,
|
||||
ReadInboxInputSchema,
|
||||
ReadConfigInputSchema,
|
||||
TeamSpawnInputSchema,
|
||||
ForceKillTeammateInputSchema,
|
||||
ProcessShutdownApprovedInputSchema,
|
||||
} from "./types"
|
||||
|
||||
describe("TeamConfigSchema", () => {
|
||||
it("validates a complete team config", () => {
|
||||
// given
|
||||
const validConfig = {
|
||||
name: "my-team",
|
||||
description: "A test team",
|
||||
createdAt: "2026-02-11T10:00:00Z",
|
||||
leadAgentId: "agent-123",
|
||||
leadSessionId: "ses-456",
|
||||
members: [
|
||||
{
|
||||
agentId: "agent-123",
|
||||
name: "Lead Agent",
|
||||
agentType: "lead",
|
||||
color: "blue",
|
||||
},
|
||||
{
|
||||
agentId: "agent-789",
|
||||
name: "Worker 1",
|
||||
agentType: "teammate",
|
||||
color: "green",
|
||||
category: "quick",
|
||||
model: "claude-sonnet-4-5",
|
||||
prompt: "You are a helpful assistant",
|
||||
planModeRequired: false,
|
||||
joinedAt: "2026-02-11T10:05:00Z",
|
||||
cwd: "/tmp",
|
||||
subscriptions: ["task-updates"],
|
||||
backendType: "native" as const,
|
||||
isActive: true,
|
||||
sessionID: "ses-789",
|
||||
backgroundTaskID: "task-123",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// when
|
||||
const result = TeamConfigSchema.safeParse(validConfig)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects invalid team config", () => {
|
||||
// given
|
||||
const invalidConfig = {
|
||||
name: "",
|
||||
description: "A test team",
|
||||
createdAt: "invalid-date",
|
||||
leadAgentId: "",
|
||||
leadSessionId: "ses-456",
|
||||
members: [],
|
||||
}
|
||||
|
||||
// when
|
||||
const result = TeamConfigSchema.safeParse(invalidConfig)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("TeamMemberSchema", () => {
|
||||
it("validates a lead member", () => {
|
||||
// given
|
||||
const leadMember = {
|
||||
agentId: "agent-123",
|
||||
name: "Lead Agent",
|
||||
agentType: "lead",
|
||||
color: "blue",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = TeamMemberSchema.safeParse(leadMember)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects invalid member", () => {
|
||||
// given
|
||||
const invalidMember = {
|
||||
agentId: "",
|
||||
name: "",
|
||||
agentType: "invalid",
|
||||
color: "invalid",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = TeamMemberSchema.safeParse(invalidMember)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("TeamTeammateMemberSchema", () => {
|
||||
it("validates a complete teammate member", () => {
|
||||
// given
|
||||
const teammateMember = {
|
||||
agentId: "agent-789",
|
||||
name: "Worker 1",
|
||||
agentType: "teammate",
|
||||
color: "green",
|
||||
category: "quick",
|
||||
model: "claude-sonnet-4-5",
|
||||
prompt: "You are a helpful assistant",
|
||||
planModeRequired: false,
|
||||
joinedAt: "2026-02-11T10:05:00Z",
|
||||
cwd: "/tmp",
|
||||
subscriptions: ["task-updates"],
|
||||
backendType: "native" as const,
|
||||
isActive: true,
|
||||
sessionID: "ses-789",
|
||||
backgroundTaskID: "task-123",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = TeamTeammateMemberSchema.safeParse(teammateMember)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("validates teammate member with optional fields missing", () => {
|
||||
// given
|
||||
const minimalTeammate = {
|
||||
agentId: "agent-789",
|
||||
name: "Worker 1",
|
||||
agentType: "teammate",
|
||||
color: "green",
|
||||
category: "quick",
|
||||
model: "claude-sonnet-4-5",
|
||||
prompt: "You are a helpful assistant",
|
||||
planModeRequired: false,
|
||||
joinedAt: "2026-02-11T10:05:00Z",
|
||||
cwd: "/tmp",
|
||||
subscriptions: [],
|
||||
backendType: "native" as const,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
// when
|
||||
const result = TeamTeammateMemberSchema.safeParse(minimalTeammate)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects invalid teammate member", () => {
|
||||
// given
|
||||
const invalidTeammate = {
|
||||
agentId: "",
|
||||
name: "Worker 1",
|
||||
agentType: "teammate",
|
||||
color: "green",
|
||||
category: "quick",
|
||||
model: "claude-sonnet-4-5",
|
||||
prompt: "You are a helpful assistant",
|
||||
planModeRequired: false,
|
||||
joinedAt: "invalid-date",
|
||||
cwd: "/tmp",
|
||||
subscriptions: [],
|
||||
backendType: "invalid" as const,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
// when
|
||||
const result = TeamTeammateMemberSchema.safeParse(invalidTeammate)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects reserved agentType for teammate schema", () => {
|
||||
// given
|
||||
const invalidTeammate = {
|
||||
agentId: "worker@team",
|
||||
name: "worker",
|
||||
agentType: "team-lead",
|
||||
category: "quick",
|
||||
model: "native",
|
||||
prompt: "do work",
|
||||
color: "blue",
|
||||
planModeRequired: false,
|
||||
joinedAt: "2026-02-11T10:05:00Z",
|
||||
cwd: "/tmp",
|
||||
subscriptions: [],
|
||||
backendType: "native",
|
||||
isActive: false,
|
||||
}
|
||||
|
||||
// when
|
||||
const result = TeamTeammateMemberSchema.safeParse(invalidTeammate)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("MessageTypeSchema", () => {
|
||||
it("validates all 5 message types", () => {
|
||||
// given
|
||||
const types = ["message", "broadcast", "shutdown_request", "shutdown_response", "plan_approval_response"]
|
||||
|
||||
// when & then
|
||||
types.forEach(type => {
|
||||
const result = MessageTypeSchema.safeParse(type)
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data).toBe(type)
|
||||
})
|
||||
})
|
||||
|
||||
it("rejects invalid message type", () => {
|
||||
// given
|
||||
const invalidType = "invalid_type"
|
||||
|
||||
// when
|
||||
const result = MessageTypeSchema.safeParse(invalidType)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("InboxMessageSchema", () => {
|
||||
it("validates a complete inbox message", () => {
|
||||
// given
|
||||
const message = {
|
||||
id: "msg-123",
|
||||
type: "message" as const,
|
||||
sender: "agent-123",
|
||||
recipient: "agent-456",
|
||||
content: "Hello world",
|
||||
summary: "Greeting",
|
||||
timestamp: "2026-02-11T10:00:00Z",
|
||||
read: false,
|
||||
requestId: "req-123",
|
||||
approve: true,
|
||||
}
|
||||
|
||||
// when
|
||||
const result = InboxMessageSchema.safeParse(message)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("validates message with optional fields missing", () => {
|
||||
// given
|
||||
const minimalMessage = {
|
||||
id: "msg-123",
|
||||
type: "broadcast" as const,
|
||||
sender: "agent-123",
|
||||
recipient: "agent-456",
|
||||
content: "Hello world",
|
||||
summary: "Greeting",
|
||||
timestamp: "2026-02-11T10:00:00Z",
|
||||
read: false,
|
||||
}
|
||||
|
||||
// when
|
||||
const result = InboxMessageSchema.safeParse(minimalMessage)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects invalid inbox message", () => {
|
||||
// given
|
||||
const invalidMessage = {
|
||||
id: "",
|
||||
type: "invalid" as const,
|
||||
sender: "",
|
||||
recipient: "",
|
||||
content: "",
|
||||
summary: "",
|
||||
timestamp: "invalid-date",
|
||||
read: "not-boolean",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = InboxMessageSchema.safeParse(invalidMessage)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("TeamTaskSchema", () => {
|
||||
it("validates a task object", () => {
|
||||
// given
|
||||
const task = {
|
||||
id: "T-12345678-1234-1234-1234-123456789012",
|
||||
subject: "Implement feature",
|
||||
description: "Add new functionality",
|
||||
status: "pending" as const,
|
||||
activeForm: "Implementing feature",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
owner: "agent-123",
|
||||
metadata: { priority: "high" },
|
||||
repoURL: "https://github.com/user/repo",
|
||||
parentID: "T-parent",
|
||||
threadID: "thread-123",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = TeamTaskSchema.safeParse(task)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects invalid task", () => {
|
||||
// given
|
||||
const invalidTask = {
|
||||
id: "invalid-id",
|
||||
subject: "",
|
||||
description: "Add new functionality",
|
||||
status: "invalid" as const,
|
||||
activeForm: "Implementing feature",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
}
|
||||
|
||||
// when
|
||||
const result = TeamTaskSchema.safeParse(invalidTask)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("TeamCreateInputSchema", () => {
|
||||
it("validates create input with description", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "my-team",
|
||||
description: "A test team",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = TeamCreateInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("validates create input without description", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "my-team",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = TeamCreateInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects invalid create input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "invalid team name with spaces and special chars!",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = TeamCreateInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("TeamDeleteInputSchema", () => {
|
||||
it("validates delete input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "my-team",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = TeamDeleteInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects invalid delete input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = TeamDeleteInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("SendMessageInputSchema", () => {
|
||||
it("validates message type input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "my-team",
|
||||
type: "message" as const,
|
||||
recipient: "agent-456",
|
||||
content: "Hello world",
|
||||
summary: "Greeting",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = SendMessageInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("validates broadcast type input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "my-team",
|
||||
type: "broadcast" as const,
|
||||
content: "Team announcement",
|
||||
summary: "Announcement",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = SendMessageInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("validates shutdown_request type input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "my-team",
|
||||
type: "shutdown_request" as const,
|
||||
recipient: "agent-456",
|
||||
content: "Please shutdown",
|
||||
summary: "Shutdown request",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = SendMessageInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("validates shutdown_response type input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "my-team",
|
||||
type: "shutdown_response" as const,
|
||||
request_id: "req-123",
|
||||
approve: true,
|
||||
}
|
||||
|
||||
// when
|
||||
const result = SendMessageInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("validates plan_approval_response type input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "my-team",
|
||||
type: "plan_approval_response" as const,
|
||||
request_id: "req-456",
|
||||
approve: false,
|
||||
}
|
||||
|
||||
// when
|
||||
const result = SendMessageInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects message type without recipient", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "my-team",
|
||||
type: "message" as const,
|
||||
content: "Hello world",
|
||||
summary: "Greeting",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = SendMessageInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects shutdown_response without request_id", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "my-team",
|
||||
type: "shutdown_response" as const,
|
||||
approve: true,
|
||||
}
|
||||
|
||||
// when
|
||||
const result = SendMessageInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects invalid team_name", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "invalid team name",
|
||||
type: "broadcast" as const,
|
||||
content: "Hello",
|
||||
summary: "Greeting",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = SendMessageInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("ReadInboxInputSchema", () => {
|
||||
it("validates read inbox input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "my-team",
|
||||
agent_name: "worker-1",
|
||||
unread_only: true,
|
||||
mark_as_read: false,
|
||||
}
|
||||
|
||||
// when
|
||||
const result = ReadInboxInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("validates minimal read inbox input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "my-team",
|
||||
agent_name: "worker-1",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = ReadInboxInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects invalid read inbox input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "",
|
||||
agent_name: "",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = ReadInboxInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("ReadConfigInputSchema", () => {
|
||||
it("validates read config input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "my-team",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = ReadConfigInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects invalid read config input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = ReadConfigInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("TeamSpawnInputSchema", () => {
|
||||
it("validates spawn input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "my-team",
|
||||
name: "worker-1",
|
||||
category: "quick",
|
||||
prompt: "You are a helpful assistant",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = TeamSpawnInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects invalid spawn input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "invalid team",
|
||||
name: "",
|
||||
category: "quick",
|
||||
prompt: "You are a helpful assistant",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = TeamSpawnInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("ForceKillTeammateInputSchema", () => {
|
||||
it("validates force kill input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "my-team",
|
||||
teammate_name: "worker-1",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = ForceKillTeammateInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects invalid force kill input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "",
|
||||
teammate_name: "",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = ForceKillTeammateInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("ProcessShutdownApprovedInputSchema", () => {
|
||||
it("validates shutdown approved input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "my-team",
|
||||
teammate_name: "worker-1",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = ProcessShutdownApprovedInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects invalid shutdown approved input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "",
|
||||
teammate_name: "",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = ProcessShutdownApprovedInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user