Compare commits

..

41 Commits

Author SHA1 Message Date
YeonGyu-Kim
8a83020b51 feat(agent-teams): register team tools behind experimental.team_system flag
- Create barrel export in src/tools/agent-teams/index.ts
- Create factory function createAgentTeamsTools() in tools.ts
- Register 7 team tools in tool-registry.ts behind experimental flag
- Add integration tests for tool registration gating
- Fix type errors: add TeamTaskStatus, update schemas
- Task 13 complete
2026-02-14 13:33:30 +09:00
YeonGyu-Kim
16e034492c feat(task): add team_name routing to task_list and task_update tools
- Add optional team_name parameter to task_list and task_update
- Route to team-namespaced storage when team_name provided
- Preserve existing behavior when team_name absent
- Add comprehensive tests for both team and regular task operations
- Task 12 complete (4/4 files: create, get, list, update)
2026-02-14 13:33:30 +09:00
YeonGyu-Kim
3d5754089e feat(task): add team_name routing to task_get tool
- Add optional team_name parameter to task_get
- Route to team-namespaced storage when team_name provided
- Preserve existing behavior when team_name absent
- Add tests for both team and regular task retrieval
- Part of Task 12 (2/4 files complete)
2026-02-14 13:33:30 +09:00
YeonGyu-Kim
eabc20de9e feat(task): add team_name routing to task_create tool
- Add optional team_name parameter to task_create
- Route to team-namespaced storage when team_name provided
- Preserve existing behavior when team_name absent
- Add tests for both team and regular task creation
- Part of Task 12 (1/4 files complete)
2026-02-14 13:33:30 +09:00
YeonGyu-Kim
48441b831c feat(agent-teams): implement teammate control tools (force_kill, process_shutdown_approved)
- Add force_kill_teammate tool for immediate teammate removal
- Add process_shutdown_approved tool for graceful shutdown processing
- Both tools validate team-lead protection and teammate status
- Comprehensive test coverage with 8 test cases
- Task 10/25 complete
2026-02-14 13:33:30 +09:00
YeonGyu-Kim
88be194805 feat(agent-teams): add read_inbox and read_config tools
- Add simple read_inbox tool as thin wrapper over readInbox store function
- Add simple read_config tool as thin wrapper over readTeamConfig store function
- Both tools support basic filtering (unread_only for inbox, none for config)
- Comprehensive test coverage with TDD approach
- Tools are separate from registered read_inbox/read_config (which have authorization)
2026-02-14 13:33:30 +09:00
YeonGyu-Kim
4a38e09a33 feat(agent-teams): add send_message tool with 5 message types
- Implement discriminated union for 5 message types
- message: requires recipient + content
- broadcast: sends to all teammates
- shutdown_request: requires recipient
- shutdown_response: requires request_id + approve
- plan_approval_response: requires request_id + approve
- 14 comprehensive tests with unique team names
- Extract inbox-message-sender.ts for message delivery logic

Task 8/25 complete
2026-02-14 13:33:30 +09:00
YeonGyu-Kim
aa83b05f1f feat(agent-teams): add team_create and team_delete tools
- Implement tool factories for team lifecycle management
- team_create: Creates team with initial config, returns team info
- team_delete: Deletes team if no active teammates
- Name validation: ^[A-Za-z0-9_-]+$, max 64 chars
- 9 comprehensive tests with unique team names per test

Task 7/25 complete
2026-02-14 13:33:30 +09:00
YeonGyu-Kim
d67138575c feat(agent-teams): add team task store with namespace routing
- Implement team-namespaced task storage at ~/.sisyphus/tasks/{teamName}/
- Follow existing task storage patterns from features/claude-tasks/storage.ts
- Import TaskObjectSchema from tools/task/types.ts (no duplication)
- Export getTeamTaskPath for test access
- 16 comprehensive tests with temp directory isolation

Task 6/25 complete
2026-02-14 13:33:30 +09:00
YeonGyu-Kim
4c52bf32cd feat(agent-teams): add inbox store with atomic message operations
- Implement atomic message append/read/mark-read operations
- Messages stored per-agent at ~/.sisyphus/teams/{team}/inboxes/{agent}.json
- Use acquireLock for concurrent access safety
- Inbox append is atomic (read-append-write under lock)
- 2 comprehensive tests with locking verification

Task 5/25 complete
2026-02-14 13:33:30 +09:00
YeonGyu-Kim
f0ae1131de feat(agent-teams): add team config store with atomic writes
- Implement CRUD operations for team config.json
- Use atomic writes with temp+rename pattern
- Reuse acquireLock for concurrent access safety
- Team config lives at ~/.sisyphus/teams/{teamName}/config.json
- deleteTeamDir removes team + inbox + task dirs recursively
- Fix timestamp: use ISO string instead of number
- 4 comprehensive tests with locking verification

Task 4/25 complete
2026-02-14 13:33:30 +09:00
YeonGyu-Kim
d65912bc63 feat(agent-teams): add team, message, and task Zod schemas
- TeamConfigSchema with lead/teammate members
- TeamMemberSchema and TeamTeammateMemberSchema
- InboxMessageSchema with 5 message types
- SendMessageInputSchema as discriminated union
- Import TaskObjectSchema from tools/task/types.ts
- 39 comprehensive tests covering all schemas

Task 3/25 complete
2026-02-14 13:33:30 +09:00
YeonGyu-Kim
3e2e4e29df feat(agent-teams): add team path resolution utilities
- Implement user-global paths (~/.sisyphus/teams/, ~/.sisyphus/tasks/)
- Reuse sanitizePathSegment for team name sanitization
- Cross-platform home directory resolution
- Comprehensive test coverage with sanitization tests

Task 2/25 complete
2026-02-14 13:33:30 +09:00
YeonGyu-Kim
5e06db0c60 feat(config): add experimental.team_system flag
- Add team_system boolean flag to ExperimentalConfigSchema
- Defaults to false
- Enables experimental agent teams toolset
- Added comprehensive BDD-style tests

Task 1/25 complete
2026-02-14 13:33:30 +09:00
YeonGyu-Kim
4282de139b feat(agent-teams): gate agent-teams tools behind experimental.agent_teams flag 2026-02-14 13:33:30 +09:00
Nguyen Khac Trung Kien
386521d185 test(agent-teams): set explicit lead agent in delegation consistency test 2026-02-14 13:33:30 +09:00
Nguyen Khac Trung Kien
accb874155 fix(agent-teams): close delete race and preserve parent-agent fallback 2026-02-14 13:33:30 +09:00
Nguyen Khac Trung Kien
1e2c10e7b0 fix(agent-teams): harden inbox parsing and behavioral tests 2026-02-14 13:33:30 +09:00
Nguyen Khac Trung Kien
a9d4cefdfe fix(agent-teams): authorize task tools by team session 2026-02-14 13:33:30 +09:00
Nguyen Khac Trung Kien
2a57feb810 fix(agent-teams): tighten config access and context propagation 2026-02-14 13:33:30 +09:00
Nguyen Khac Trung Kien
f422cfc7af fix(agent-teams): harden deletion and messaging safety 2026-02-14 13:33:30 +09:00
Nguyen Khac Trung Kien
0f0ba0f71b fix(agent-teams): address race condition in team deletion locking 2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
c15bad6d00 fix(agent-teams): enforce lead spawn auth and dedupe shutdown 2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
805df45722 fix(agent-teams): lock team deletion behind config mutex 2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
cf42082c5f fix(agent-teams): accept teammate agent IDs in messaging
Normalize send_message recipients so name@team values resolve to member names, preventing false recipient-not-found fallbacks into duplicate delegation paths. Also add delegation consistency coverage and split teammate runtime helpers for clearer spawn and parent-context handling.
2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
40f844fb85 fix(agent-teams): align spawn schema and harden inbox rollback behavior 2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
fe05a1f254 fix(agent-teams): harden lead auth and require teammate categories 2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
e984ce7493 feat(agent-teams): support category-based teammate spawning 2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
3f859828cc fix(agent-teams): rotate lead session and clear stale teammate inbox 2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
11766b085d fix(agent-teams): enforce T-prefixed task id validation 2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
2103061123 fix(agent-teams): close latest review gaps for auth and race safety 2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
79c3823762 fix(agent-teams): enforce session-bound messaging and shutdown cleanup 2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
dc3d81a0b8 fix(agent-teams): tighten reviewer-raised runtime and messaging guards
Validate sender/owner/team flows more strictly, fail fast on invalid model overrides, and cancel failed launches to prevent orphaned background tasks while expanding functional coverage for these paths.
2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
7ad60cbedb fix(agent-teams): atomically write inbox files 2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
1a5030d359 fix(agent-teams): fail fast on teammate launch errors 2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
dbcad8fd97 fix(agent-teams): harden task operations against traversal 2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
0ec6afcd9e fix(agent-teams): move team existence check under lock 2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
f4e4fdb2e4 fix(agent-teams): add strict identifier validation rules 2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
db08cc22cc test(agent-teams): add functional and utility coverage 2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
766794e0f5 fix(agent-teams): store data under project .sisyphus 2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
0f9c93fd55 feat(tools): add native team orchestration tool suite
Port team lifecycle, teammate runtime, inbox messaging, and team-scoped task flows into built-in tools so multi-agent coordination works natively without external server dependencies.
2026-02-14 13:33:29 +09:00
127 changed files with 8642 additions and 2457 deletions

View File

@@ -52,31 +52,12 @@ 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: |
# 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 \
# Run all other tests (mock-heavy ones are re-run but that's acceptable)
bun test bin script src/cli src/config src/mcp src/index.test.ts \
src/agents src/tools src/shared \
src/hooks/anthropic-context-window-limit-recovery \
src/hooks/claude-code-compatibility \
src/hooks/context-injection \
@@ -89,11 +70,7 @@ jobs:
src/features/builtin-skills \
src/features/claude-code-session-state \
src/features/hook-message-injector \
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/opencode-skill-loader \
src/features/skill-mcp-manager
typecheck:

View File

@@ -51,33 +51,13 @@ 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: |
# 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 \
# Run all other tests (mock-heavy ones are re-run but that's acceptable)
bun test bin script src/cli src/config src/mcp src/index.test.ts \
src/agents src/tools src/shared \
src/hooks/anthropic-context-window-limit-recovery \
src/hooks/claude-code-compatibility \
src/hooks/context-injection \
@@ -90,11 +70,7 @@ jobs:
src/features/builtin-skills \
src/features/claude-code-session-state \
src/features/hook-message-injector \
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/opencode-skill-loader \
src/features/skill-mcp-manager
typecheck:

View File

@@ -162,6 +162,9 @@
},
"tools": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "boolean"
}
@@ -207,6 +210,9 @@
},
{
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string",
"enum": [
@@ -294,6 +300,9 @@
},
"providerOptions": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
}
},
@@ -335,6 +344,9 @@
},
"tools": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "boolean"
}
@@ -380,6 +392,9 @@
},
{
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string",
"enum": [
@@ -467,6 +482,9 @@
},
"providerOptions": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
}
},
@@ -508,6 +526,9 @@
},
"tools": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "boolean"
}
@@ -553,6 +574,9 @@
},
{
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string",
"enum": [
@@ -640,6 +664,9 @@
},
"providerOptions": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
}
},
@@ -681,6 +708,9 @@
},
"tools": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "boolean"
}
@@ -726,6 +756,9 @@
},
{
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string",
"enum": [
@@ -813,6 +846,9 @@
},
"providerOptions": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
}
},
@@ -854,6 +890,9 @@
},
"tools": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "boolean"
}
@@ -899,6 +938,9 @@
},
{
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string",
"enum": [
@@ -986,6 +1028,9 @@
},
"providerOptions": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
}
},
@@ -1027,6 +1072,9 @@
},
"tools": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "boolean"
}
@@ -1072,6 +1120,9 @@
},
{
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string",
"enum": [
@@ -1159,6 +1210,9 @@
},
"providerOptions": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
}
},
@@ -1200,6 +1254,9 @@
},
"tools": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "boolean"
}
@@ -1245,6 +1302,9 @@
},
{
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string",
"enum": [
@@ -1332,6 +1392,9 @@
},
"providerOptions": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
}
},
@@ -1373,6 +1436,9 @@
},
"tools": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "boolean"
}
@@ -1418,6 +1484,9 @@
},
{
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string",
"enum": [
@@ -1505,6 +1574,9 @@
},
"providerOptions": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
}
},
@@ -1546,6 +1618,9 @@
},
"tools": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "boolean"
}
@@ -1591,6 +1666,9 @@
},
{
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string",
"enum": [
@@ -1678,6 +1756,9 @@
},
"providerOptions": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
}
},
@@ -1719,6 +1800,9 @@
},
"tools": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "boolean"
}
@@ -1764,6 +1848,9 @@
},
{
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string",
"enum": [
@@ -1851,6 +1938,9 @@
},
"providerOptions": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
}
},
@@ -1892,6 +1982,9 @@
},
"tools": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "boolean"
}
@@ -1937,6 +2030,9 @@
},
{
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string",
"enum": [
@@ -2024,6 +2120,9 @@
},
"providerOptions": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
}
},
@@ -2065,6 +2164,9 @@
},
"tools": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "boolean"
}
@@ -2110,6 +2212,9 @@
},
{
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string",
"enum": [
@@ -2197,6 +2302,9 @@
},
"providerOptions": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
}
},
@@ -2238,6 +2346,9 @@
},
"tools": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "boolean"
}
@@ -2283,6 +2394,9 @@
},
{
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string",
"enum": [
@@ -2370,6 +2484,9 @@
},
"providerOptions": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
}
},
@@ -2411,6 +2528,9 @@
},
"tools": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "boolean"
}
@@ -2456,6 +2576,9 @@
},
{
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string",
"enum": [
@@ -2543,6 +2666,9 @@
},
"providerOptions": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
}
},
@@ -2553,6 +2679,9 @@
},
"categories": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "object",
"properties": {
@@ -2616,6 +2745,9 @@
},
"tools": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "boolean"
}
@@ -2656,6 +2788,9 @@
},
"plugins_override": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "boolean"
}
@@ -2926,6 +3061,9 @@
},
"metadata": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"allowed-tools": {
@@ -2977,6 +3115,9 @@
},
"providerConcurrency": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "number",
"minimum": 0
@@ -2984,6 +3125,9 @@
},
"modelConcurrency": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "number",
"minimum": 0
@@ -2992,10 +3136,6 @@
"staleTimeoutMs": {
"type": "number",
"minimum": 60000
},
"messageStalenessTimeoutMs": {
"type": "number",
"minimum": 60000
}
},
"additionalProperties": false

View File

@@ -28,13 +28,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.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",
},
},
},
@@ -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.3", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Dq0+PC2dyAqG7c3DUnQmdOkKbKmOsRHwoqgLCQNKN1lTRllF8zbWqp5B+LGKxSPxPqJIPS3mKt+wIR2KvkYJVw=="],
"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-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-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-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": ["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-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-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-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": ["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-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-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-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=="],
"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=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode",
"version": "3.5.5",
"version": "3.5.3",
"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.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"
"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"
},
"trustedDependencies": [
"@ast-grep/cli",

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-arm64",
"version": "3.5.5",
"version": "3.5.3",
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-x64",
"version": "3.5.5",
"version": "3.5.3",
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-arm64-musl",
"version": "3.5.5",
"version": "3.5.3",
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-arm64",
"version": "3.5.5",
"version": "3.5.3",
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-x64-musl",
"version": "3.5.5",
"version": "3.5.3",
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-x64",
"version": "3.5.5",
"version": "3.5.3",
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-windows-x64",
"version": "3.5.5",
"version": "3.5.3",
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
"license": "MIT",
"repository": {

View File

@@ -1471,38 +1471,6 @@
"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
}
]
}

View File

@@ -247,7 +247,7 @@ exports[`generateModelConfig single native provider uses OpenAI models when only
"model": "opencode/glm-4.7-free",
},
"writing": {
"model": "opencode/glm-4.7-free",
"model": "openai/gpt-5.2",
},
},
}
@@ -314,7 +314,7 @@ exports[`generateModelConfig single native provider uses OpenAI models with isMa
"model": "opencode/glm-4.7-free",
},
"writing": {
"model": "opencode/glm-4.7-free",
"model": "openai/gpt-5.2",
},
},
}
@@ -372,7 +372,6 @@ 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",
@@ -433,7 +432,6 @@ 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",
@@ -507,7 +505,6 @@ 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",
@@ -582,7 +579,6 @@ 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",
@@ -656,7 +652,6 @@ 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",
@@ -731,7 +726,6 @@ 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",
@@ -805,7 +799,6 @@ 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",
@@ -880,7 +873,6 @@ 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",
@@ -935,10 +927,10 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian whe
"model": "opencode/glm-4.7-free",
},
"visual-engineering": {
"model": "zai-coding-plan/glm-5",
"model": "zai-coding-plan/glm-4.7",
},
"writing": {
"model": "opencode/glm-4.7-free",
"model": "zai-coding-plan/glm-4.7",
},
},
}
@@ -990,10 +982,10 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian wit
"model": "opencode/glm-4.7-free",
},
"visual-engineering": {
"model": "zai-coding-plan/glm-5",
"model": "zai-coding-plan/glm-4.7",
},
"writing": {
"model": "opencode/glm-4.7-free",
"model": "zai-coding-plan/glm-4.7",
},
},
}
@@ -1064,7 +1056,6 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen
},
"visual-engineering": {
"model": "opencode/gemini-3-pro",
"variant": "high",
},
"writing": {
"model": "opencode/gemini-3-flash",
@@ -1138,7 +1129,6 @@ 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",
@@ -1199,7 +1189,8 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + ZAI combinat
"model": "anthropic/claude-sonnet-4-5",
},
"visual-engineering": {
"model": "zai-coding-plan/glm-5",
"model": "anthropic/claude-opus-4-6",
"variant": "max",
},
"writing": {
"model": "anthropic/claude-sonnet-4-5",
@@ -1265,7 +1256,6 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
},
"visual-engineering": {
"model": "google/gemini-3-pro",
"variant": "high",
},
"writing": {
"model": "google/gemini-3-flash",
@@ -1339,7 +1329,6 @@ 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",
@@ -1413,7 +1402,6 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
},
"visual-engineering": {
"model": "google/gemini-3-pro",
"variant": "high",
},
"writing": {
"model": "google/gemini-3-flash",
@@ -1488,7 +1476,6 @@ 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",

View File

@@ -1,8 +1,6 @@
/// <reference types="bun-types" />
import { describe, it, expect, spyOn, afterEach } from "bun:test"
import { describe, it, expect } from "bun:test"
import type { OhMyOpenCodeConfig } from "../../config"
import { resolveRunAgent, waitForEventProcessorShutdown } from "./runner"
import { resolveRunAgent } from "./runner"
const createConfig = (overrides: Partial<OhMyOpenCodeConfig> = {}): OhMyOpenCodeConfig => ({
...overrides,
@@ -70,59 +68,3 @@ 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()
}
})
})

View File

@@ -12,25 +12,6 @@ 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"
@@ -100,14 +81,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 waitForEventProcessorShutdown(eventProcessor)
cleanup()
await eventProcessor
cleanup()
const durationMs = Date.now() - startTime
@@ -146,3 +127,4 @@ export async function run(options: RunOptions): Promise<number> {
return 1
}
}

View File

@@ -649,7 +649,21 @@ describe("ExperimentalConfigSchema feature flags", () => {
}
})
test("both fields are optional", () => {
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", () => {
//#given
const config = {}
@@ -659,10 +673,34 @@ describe("ExperimentalConfigSchema feature flags", () => {
//#then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.plugin_load_timeout_ms).toBeUndefined()
expect(result.data.safe_hook_creation).toBeUndefined()
expect(result.data.team_system).toBe(false)
}
})
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", () => {

View File

@@ -6,8 +6,6 @@ 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>

View File

@@ -15,6 +15,10 @@ 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>

View File

@@ -52,7 +52,7 @@ export function handleBackgroundEvent(args: {
const props = event.properties
if (event.type === "message.part.updated" || event.type === "message.part.delta") {
if (event.type === "message.part.updated") {
if (!props || !isRecord(props)) return
const sessionID = getString(props, "sessionID")
if (!sessionID) return

View File

@@ -4,7 +4,6 @@ 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

View File

@@ -2289,221 +2289,10 @@ 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", () => {
@@ -3413,44 +3202,4 @@ 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")
})
})

View File

@@ -7,12 +7,10 @@ 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,
@@ -143,7 +141,6 @@ export class BackgroundManager {
parentMessageID: input.parentMessageID,
parentModel: input.parentModel,
parentAgent: input.parentAgent,
parentTools: input.parentTools,
model: input.model,
category: input.category,
}
@@ -331,16 +328,12 @@ export class BackgroundManager {
...(launchModel ? { model: launchModel } : {}),
...(launchVariant ? { variant: launchVariant } : {}),
system: input.skillContent,
tools: (() => {
const tools = {
...getAgentToolRestrictions(input.agent),
task: false,
call_omo_agent: true,
question: false,
}
setSessionTools(sessionID, tools)
return tools
})(),
tools: {
...getAgentToolRestrictions(input.agent),
task: false,
call_omo_agent: true,
question: false,
},
parts: [{ type: "text", text: input.prompt }],
},
}).catch((error) => {
@@ -542,9 +535,6 @@ 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()
@@ -598,16 +588,12 @@ export class BackgroundManager {
agent: existingTask.agent,
...(resumeModel ? { model: resumeModel } : {}),
...(resumeVariant ? { variant: resumeVariant } : {}),
tools: (() => {
const tools = {
...getAgentToolRestrictions(existingTask.agent),
task: false,
call_omo_agent: true,
question: false,
}
setSessionTools(existingTask.sessionID!, tools)
return tools
})(),
tools: {
...getAgentToolRestrictions(existingTask.agent),
task: false,
call_omo_agent: true,
question: false,
},
parts: [{ type: "text", text: input.prompt }],
},
}).catch((error) => {
@@ -660,7 +646,7 @@ export class BackgroundManager {
handleEvent(event: Event): void {
const props = event.properties
if (event.type === "message.part.updated" || event.type === "message.part.delta") {
if (event.type === "message.part.updated") {
if (!props || typeof props !== "object" || !("sessionID" in props)) return
const partInfo = props as unknown as MessagePartInfo
const sessionID = partInfo?.sessionID
@@ -1266,7 +1252,6 @@ 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 }],
},
})
@@ -1438,55 +1423,24 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
}
}
private async checkAndInterruptStaleTasks(
allStatuses: Record<string, { type: string }> = {},
): Promise<void> {
private async checkAndInterruptStaleTasks(): 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)
@@ -1499,7 +1453,10 @@ 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 {
@@ -1512,12 +1469,11 @@ 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
@@ -1527,6 +1483,7 @@ 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)

View File

@@ -148,7 +148,6 @@ export async function notifyParentSession(args: {
noReply: !allComplete,
...(agent !== undefined ? { agent } : {}),
...(model !== undefined ? { model } : {}),
...(task.parentTools ? { tools: task.parentTools } : {}),
parts: [{ type: "text", text: notification }],
},
})

View File

@@ -71,7 +71,6 @@ export async function notifyParentSession(
noReply: !allComplete,
...(agent !== undefined ? { agent } : {}),
...(model !== undefined ? { model } : {}),
...(task.parentTools ? { tools: task.parentTools } : {}),
parts: [{ type: "text", text: notification }],
},
})

View File

@@ -34,7 +34,7 @@ export async function pollRunningTasks(args: {
tasks: Iterable<BackgroundTask>
client: OpencodeClient
pruneStaleTasksAndNotifications: () => void
checkAndInterruptStaleTasks: (statuses: Record<string, { type: string }>) => Promise<void>
checkAndInterruptStaleTasks: () => Promise<void>
validateSessionHasOutput: (sessionID: string) => Promise<boolean>
checkSessionTodos: (sessionID: string) => Promise<boolean>
tryCompleteTask: (task: BackgroundTask, source: string) => Promise<boolean>
@@ -54,12 +54,11 @@ 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

View File

@@ -13,7 +13,6 @@ export function createTask(input: LaunchInput): BackgroundTask {
parentMessageID: input.parentMessageID,
parentModel: input.parentModel,
parentAgent: input.parentAgent,
parentTools: input.parentTools,
model: input.model,
}
}

View File

@@ -1,6 +1,5 @@
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"
@@ -36,9 +35,6 @@ 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 = {
@@ -79,16 +75,12 @@ export async function resumeTask(
agent: task.agent,
...(resumeModel ? { model: resumeModel } : {}),
...(resumeVariant ? { variant: resumeVariant } : {}),
tools: (() => {
const tools = {
...getAgentToolRestrictions(task.agent),
task: false,
call_omo_agent: true,
question: false,
}
setSessionTools(task.sessionID!, tools)
return tools
})(),
tools: {
...getAgentToolRestrictions(task.agent),
task: false,
call_omo_agent: true,
question: false,
},
parts: [{ type: "text", text: input.prompt }],
},
})

View File

@@ -1,6 +1,5 @@
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"
@@ -80,16 +79,12 @@ export async function startTask(item: QueueItem, ctx: SpawnerContext): Promise<v
...(launchModel ? { model: launchModel } : {}),
...(launchVariant ? { variant: launchVariant } : {}),
system: input.skillContent,
tools: (() => {
const tools = {
...getAgentToolRestrictions(input.agent),
task: false,
call_omo_agent: true,
question: false,
}
setSessionTools(sessionID, tools)
return tools
})(),
tools: {
...getAgentToolRestrictions(input.agent),
task: false,
call_omo_agent: true,
question: false,
},
parts: [{ type: "text", text: input.prompt }],
},
}).catch((error: unknown) => {

View File

@@ -1,425 +0,0 @@
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")
})
})

View File

@@ -6,7 +6,6 @@ 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,
@@ -57,60 +56,26 @@ 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, sessionStatuses } = args
const { tasks, client, config, concurrencyManager, notifyParentSession } = 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()
@@ -127,7 +92,10 @@ 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 {

View File

@@ -37,8 +37,6 @@ 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') */
@@ -58,7 +56,6 @@ 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[]
@@ -73,5 +70,4 @@ export interface ResumeInput {
parentMessageID: string
parentModel?: { providerID: string; modelID: string }
parentAgent?: string
parentTools?: Record<string, boolean>
}

View File

@@ -1,48 +0,0 @@
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")
})
})

View File

@@ -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,60 +560,4 @@ 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")
})
})
})

View File

@@ -1,5 +1,4 @@
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"
@@ -39,25 +38,15 @@ export interface DiscoverSkillsOptions {
}
export async function discoverAllSkills(directory?: string): Promise<LoadedSkill[]> {
const [opencodeProjectSkills, opencodeGlobalSkills, projectSkills, userSkills, agentsProjectSkills, agentsGlobalSkills] =
await Promise.all([
discoverOpencodeProjectSkills(directory),
discoverOpencodeGlobalSkills(),
discoverProjectClaudeSkills(directory),
discoverUserClaudeSkills(),
discoverProjectAgentsSkills(directory),
discoverGlobalAgentsSkills(),
])
// Priority: opencode-project > opencode > project (.claude + .agents) > user (.claude + .agents)
return deduplicateSkillsByName([
...opencodeProjectSkills,
...opencodeGlobalSkills,
...projectSkills,
...agentsProjectSkills,
...userSkills,
...agentsGlobalSkills,
const [opencodeProjectSkills, opencodeGlobalSkills, projectSkills, userSkills] = await Promise.all([
discoverOpencodeProjectSkills(directory),
discoverOpencodeGlobalSkills(),
discoverProjectClaudeSkills(directory),
discoverUserClaudeSkills(),
])
// Priority: opencode-project > opencode > project > user
return deduplicateSkillsByName([...opencodeProjectSkills, ...opencodeGlobalSkills, ...projectSkills, ...userSkills])
}
export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promise<LoadedSkill[]> {
@@ -73,22 +62,13 @@ export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promi
return deduplicateSkillsByName([...opencodeProjectSkills, ...opencodeGlobalSkills])
}
const [projectSkills, userSkills, agentsProjectSkills, agentsGlobalSkills] = await Promise.all([
const [projectSkills, userSkills] = await Promise.all([
discoverProjectClaudeSkills(directory),
discoverUserClaudeSkills(),
discoverProjectAgentsSkills(directory),
discoverGlobalAgentsSkills(),
])
// Priority: opencode-project > opencode > project (.claude + .agents) > user (.claude + .agents)
return deduplicateSkillsByName([
...opencodeProjectSkills,
...opencodeGlobalSkills,
...projectSkills,
...agentsProjectSkills,
...userSkills,
...agentsGlobalSkills,
])
// Priority: opencode-project > opencode > project > user
return deduplicateSkillsByName([...opencodeProjectSkills, ...opencodeGlobalSkills, ...projectSkills, ...userSkills])
}
export async function getSkillByName(name: string, options: DiscoverSkillsOptions = {}): Promise<LoadedSkill | undefined> {
@@ -116,13 +96,3 @@ 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" })
}

View File

@@ -351,47 +351,4 @@ 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)
})
})

View File

@@ -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,7 +27,6 @@ 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(
@@ -35,7 +34,7 @@ export function calculateCapacity(
Math.max(
0,
Math.floor(
(availableWidth + DIVIDER_SIZE) / (minPaneWidth + DIVIDER_SIZE),
(availableWidth + DIVIDER_SIZE) / (MIN_PANE_WIDTH + DIVIDER_SIZE),
),
),
)

View File

@@ -1,4 +1,3 @@
import { MIN_PANE_HEIGHT, MIN_PANE_WIDTH } from "./types"
import type { SplitDirection, TmuxPaneInfo } from "./types"
import {
DIVIDER_SIZE,
@@ -8,10 +7,6 @@ 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)))
@@ -26,32 +21,26 @@ 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 >= minSplitWidthFor(minPaneWidth)
return columnWidth >= MIN_SPLIT_WIDTH
}
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, minPaneWidth)) {
if (isSplittableAtCount(agentAreaWidth, currentCount - k)) {
return k
}
}
return null
}
export function canSplitPane(
pane: TmuxPaneInfo,
direction: SplitDirection,
minPaneWidth: number = MIN_PANE_WIDTH,
): boolean {
export function canSplitPane(pane: TmuxPaneInfo, direction: SplitDirection): boolean {
if (direction === "-h") {
return pane.width >= minSplitWidthFor(minPaneWidth)
return pane.width >= MIN_SPLIT_WIDTH
}
return pane.height >= MIN_SPLIT_HEIGHT
}

View File

@@ -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 < minPaneWidth) {
if (agentAreaWidth < MIN_PANE_WIDTH) {
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", minPaneWidth)) {
if (canSplitPane(virtualMainPane, "-h")) {
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, minPaneWidth)) {
if (isSplittableAtCount(agentAreaWidth, currentCount)) {
const spawnTarget = findSpawnTarget(state)
if (spawnTarget) {
return {
@@ -79,7 +79,7 @@ export function decideSpawnActions(
}
}
const minEvictions = findMinimalEvictions(agentAreaWidth, currentCount, minPaneWidth)
const minEvictions = findMinimalEvictions(agentAreaWidth, currentCount)
if (minEvictions === 1 && oldestPane) {
return {
canSpawn: true,

View File

@@ -1,97 +0,0 @@
/// <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()
})
})

View File

@@ -74,14 +74,6 @@ 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 {

View File

@@ -10,9 +10,18 @@ import {
} from "../../features/boulder-state"
import type { BoulderState } from "../../features/boulder-state"
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
import { _resetForTesting, subagentSessions } from "../../features/claude-code-session-state"
import { createAtlasHook } from "./index"
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")
describe("atlas hook", () => {
let TEST_DIR: string
@@ -68,6 +77,7 @@ 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", () => {
@@ -621,14 +631,15 @@ describe("atlas hook", () => {
}
beforeEach(() => {
_resetForTesting()
subagentSessions.clear()
mock.module("../../features/claude-code-session-state", () => ({
getMainSessionID: () => MAIN_SESSION_ID,
subagentSessions: new Set<string>(),
}))
setupMessageStorage(MAIN_SESSION_ID, "atlas")
})
afterEach(() => {
cleanupMessageStorage(MAIN_SESSION_ID)
_resetForTesting()
})
test("should inject continuation when boulder has incomplete tasks", async () => {

View File

@@ -1,67 +1,53 @@
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"
import { beforeEach, describe, expect, it, mock } from "bun:test"
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>) => {})
describe("processFilePathForAgentsInjection", () => {
let testRoot = ""
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", () => {
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 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")
const cachedDirectory = "/repo/src"
loadInjectedPathsMock.mockReturnValueOnce(new Set([cachedDirectory]))
findAgentsMdUpMock.mockReturnValueOnce([agentsPath])
findAgentsMdUpMock.mockReturnValueOnce(["/repo/src/AGENTS.md"])
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: repoRoot } as never,
ctx: { directory: "/repo" } as never,
truncator: truncator as never,
sessionCaches: new Map(),
filePath: join(repoRoot, "src", "file.ts"),
filePath: "/repo/src/file.ts",
sessionID,
output: { title: "Result", output: "", metadata: {} },
})
@@ -73,36 +59,19 @@ 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([agentsPath])
findAgentsMdUpMock.mockReturnValueOnce(["/repo/src/AGENTS.md"])
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: repoRoot } as never,
ctx: { directory: "/repo" } as never,
truncator: truncator as never,
sessionCaches: new Map(),
filePath: join(repoRoot, "src", "file.ts"),
filePath: "/repo/src/file.ts",
sessionID,
output: { title: "Result", output: "", metadata: {} },
})
@@ -111,44 +80,28 @@ describe("processFilePathForAgentsInjection", () => {
expect(saveInjectedPathsMock).toHaveBeenCalledTimes(1)
const saveCall = saveInjectedPathsMock.mock.calls[0]
expect(saveCall[0]).toBe(sessionID)
expect((saveCall[1] as Set<string>).has(injectedDirectory)).toBe(true)
expect((saveCall[1] as Set<string>).has("/repo/src")).toBe(true)
})
it("saves once when cached and new paths are mixed", async () => {
//#given
const sessionID = "session-3"
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])
loadInjectedPathsMock.mockReturnValueOnce(new Set(["/repo/already-cached"]))
findAgentsMdUpMock.mockReturnValueOnce([
"/repo/already-cached/AGENTS.md",
"/repo/new-dir/AGENTS.md",
])
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: repoRoot } as never,
ctx: { directory: "/repo" } as never,
truncator: truncator as never,
sessionCaches: new Map(),
filePath: join(repoRoot, "new-dir", "file.ts"),
filePath: "/repo/new-dir/file.ts",
sessionID,
output: { title: "Result", output: "", metadata: {} },
})
@@ -156,6 +109,6 @@ describe("processFilePathForAgentsInjection", () => {
//#then
expect(saveInjectedPathsMock).toHaveBeenCalledTimes(1)
const saveCall = saveInjectedPathsMock.mock.calls[0]
expect((saveCall[1] as Set<string>).has(join(repoRoot, "new-dir"))).toBe(true)
expect((saveCall[1] as Set<string>).has("/repo/new-dir")).toBe(true)
})
})

View File

@@ -1,67 +1,53 @@
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"
import { beforeEach, describe, expect, it, mock } from "bun:test"
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>) => {})
describe("processFilePathForReadmeInjection", () => {
let testRoot = ""
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", () => {
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 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")
const cachedDirectory = "/repo/src"
loadInjectedPathsMock.mockReturnValueOnce(new Set([cachedDirectory]))
findReadmeMdUpMock.mockReturnValueOnce([readmePath])
findReadmeMdUpMock.mockReturnValueOnce(["/repo/src/README.md"])
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: repoRoot } as never,
ctx: { directory: "/repo" } as never,
truncator: truncator as never,
sessionCaches: new Map(),
filePath: join(repoRoot, "src", "file.ts"),
filePath: "/repo/src/file.ts",
sessionID,
output: { title: "Result", output: "", metadata: {} },
})
@@ -73,36 +59,19 @@ 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([readmePath])
findReadmeMdUpMock.mockReturnValueOnce(["/repo/src/README.md"])
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: repoRoot } as never,
ctx: { directory: "/repo" } as never,
truncator: truncator as never,
sessionCaches: new Map(),
filePath: join(repoRoot, "src", "file.ts"),
filePath: "/repo/src/file.ts",
sessionID,
output: { title: "Result", output: "", metadata: {} },
})
@@ -111,44 +80,28 @@ describe("processFilePathForReadmeInjection", () => {
expect(saveInjectedPathsMock).toHaveBeenCalledTimes(1)
const saveCall = saveInjectedPathsMock.mock.calls[0]
expect(saveCall[0]).toBe(sessionID)
expect((saveCall[1] as Set<string>).has(injectedDirectory)).toBe(true)
expect((saveCall[1] as Set<string>).has("/repo/src")).toBe(true)
})
it("saves once when cached and new paths are mixed", async () => {
//#given
const sessionID = "session-3"
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])
loadInjectedPathsMock.mockReturnValueOnce(new Set(["/repo/already-cached"]))
findReadmeMdUpMock.mockReturnValueOnce([
"/repo/already-cached/README.md",
"/repo/new-dir/README.md",
])
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: repoRoot } as never,
ctx: { directory: "/repo" } as never,
truncator: truncator as never,
sessionCaches: new Map(),
filePath: join(repoRoot, "new-dir", "file.ts"),
filePath: "/repo/new-dir/file.ts",
sessionID,
output: { title: "Result", output: "", metadata: {} },
})
@@ -156,6 +109,6 @@ describe("processFilePathForReadmeInjection", () => {
//#then
expect(saveInjectedPathsMock).toHaveBeenCalledTimes(1)
const saveCall = saveInjectedPathsMock.mock.calls[0]
expect((saveCall[1] as Set<string>).has(join(repoRoot, "new-dir"))).toBe(true)
expect((saveCall[1] as Set<string>).has("/repo/new-dir")).toBe(true)
})
})

View File

@@ -1,4 +1,4 @@
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
@@ -6,8 +6,18 @@ import { randomUUID } from "node:crypto"
import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive"
import { clearSessionAgent } from "../../features/claude-code-session-state"
import { createPrometheusMdOnlyHook } from "./index"
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
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")
describe("prometheus-md-only", () => {
const TEST_SESSION_ID = "test-session-prometheus"
@@ -42,6 +52,7 @@ describe("prometheus-md-only", () => {
// ignore
}
}
rmSync(TEST_STORAGE_ROOT, { recursive: true, force: true })
})
describe("agent name matching", () => {

View File

@@ -1,4 +1,4 @@
import { afterAll, afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
import { 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,10 +102,6 @@ function getInjectedRulesPath(sessionID: string): string {
}
describe("createRuleInjectionProcessor", () => {
afterAll(() => {
mock.restore();
});
let testRoot: string;
let projectRoot: string;
let homeRoot: string;

View File

@@ -1,129 +0,0 @@
/// <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()
})
})

View File

@@ -34,48 +34,40 @@ function getErrorMessage(error: unknown): string {
}
export function extractMessageIndex(error: unknown): number | null {
try {
const message = getErrorMessage(error)
const match = message.match(/messages\.(\d+)/)
return match ? parseInt(match[1], 10) : null
} catch {
return null
}
const message = getErrorMessage(error)
const match = message.match(/messages\.(\d+)/)
return match ? parseInt(match[1], 10) : null
}
export function detectErrorType(error: unknown): RecoveryErrorType {
try {
const message = getErrorMessage(error)
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("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("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
}

View File

@@ -10,45 +10,6 @@ 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)
@@ -114,9 +75,7 @@ export function createThinkModeHook() {
sessionID,
provider: currentModel.providerID,
})
} else if (
!isDisabledThinkingConfig(thinkingConfig as Record<string, unknown>)
) {
} else {
Object.assign(output.message, thinkingConfig)
state.thinkingConfigInjected = true
log("Think mode: thinking config injected", {
@@ -124,11 +83,6 @@ export function createThinkModeHook() {
provider: currentModel.providerID,
config: thinkingConfig,
})
} else {
log("Think mode: skipping disabled thinking config", {
sessionID,
provider: currentModel.providerID,
})
}
}

View File

@@ -352,25 +352,6 @@ 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()

View File

@@ -470,12 +470,10 @@ 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 a Z.ai GLM model
// given zai-coding-plan provider with glm-4.7 model
const config = getThinkingConfig("zai-coding-plan", "glm-4.7")
//#when thinking config is resolved
//#then thinking type is "disabled"
// then should return zai-coding-plan thinking config
expect(config).not.toBeNull()
expect(config?.providerOptions).toBeDefined()
const zaiOptions = (config?.providerOptions as Record<string, unknown>)?.[
@@ -484,7 +482,8 @@ 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("disabled")
expect((extraBody?.thinking as Record<string, unknown>)?.type).toBe("enabled")
expect((extraBody?.thinking as Record<string, unknown>)?.clear_thinking).toBe(false)
})
it("should return thinking config for glm-4.6v (multimodal)", () => {
@@ -506,7 +505,7 @@ describe("think-mode switcher", () => {
})
describe("HIGH_VARIANT_MAP for GLM", () => {
it("should NOT have high variant for glm-4.7", () => {
it("should NOT have high variant for glm-4.7 (thinking enabled by default)", () => {
// given glm-4.7 model
const variant = getHighVariant("glm-4.7")

View File

@@ -154,7 +154,8 @@ export const THINKING_CONFIGS = {
"zai-coding-plan": {
extra_body: {
thinking: {
type: "disabled",
type: "enabled",
clear_thinking: false,
},
},
},

View File

@@ -1,4 +1,3 @@
/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import type { BackgroundManager } from "../../features/background-agent"
@@ -10,13 +9,10 @@ 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
@@ -56,41 +52,20 @@ function createFakeTimers(): FakeTimers {
}
globalThis.setTimeout = ((callback: TimerCallback, delay?: number, ...args: any[]) => {
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>
return schedule(callback, delay, null, args) as unknown as ReturnType<typeof setTimeout>
}) as typeof setTimeout
globalThis.setInterval = ((callback: TimerCallback, delay?: number, ...args: any[]) => {
const interval = normalizeDelay(delay)
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>
return schedule(callback, delay, interval, args) as unknown as ReturnType<typeof setInterval>
}) as typeof setInterval
globalThis.clearTimeout = ((id?: Parameters<typeof clearTimeout>[0]) => {
if (typeof id === "number" && timers.has(id)) {
clear(id)
return
}
original.clearTimeout(id)
globalThis.clearTimeout = ((id?: number) => {
clear(id)
}) as typeof clearTimeout
globalThis.clearInterval = ((id?: Parameters<typeof clearInterval>[0]) => {
if (typeof id === "number" && timers.has(id)) {
clear(id)
return
}
original.clearInterval(id)
globalThis.clearInterval = ((id?: number) => {
clear(id)
}) as typeof clearInterval
Date.now = () => clockNow
@@ -132,12 +107,6 @@ 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
@@ -146,7 +115,7 @@ function createFakeTimers(): FakeTimers {
Date.now = original.dateNow
}
return { advanceBy, advanceClockBy, restore }
return { advanceBy, restore }
}
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
@@ -541,7 +510,7 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(2500, true)
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true)
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
@@ -549,7 +518,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
@@ -565,26 +534,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.advanceClockBy(CONTINUATION_COOLDOWN_MS)
await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true)
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true)
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true)
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true)
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
@@ -644,7 +613,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

View File

@@ -12,8 +12,6 @@ import {
discoverProjectClaudeSkills,
discoverOpencodeGlobalSkills,
discoverOpencodeProjectSkills,
discoverProjectAgentsSkills,
discoverGlobalAgentsSkills,
mergeSkills,
} from "../features/opencode-skill-loader"
import { createBuiltinSkills } from "../features/builtin-skills"
@@ -57,7 +55,7 @@ export async function createSkillContext(args: {
})
const includeClaudeSkills = pluginConfig.claude_code?.skills !== false
const [configSourceSkills, userSkills, globalSkills, projectSkills, opencodeProjectSkills, agentsProjectSkills, agentsGlobalSkills] =
const [configSourceSkills, userSkills, globalSkills, projectSkills, opencodeProjectSkills] =
await Promise.all([
discoverConfigSourceSkills({
config: pluginConfig.skills,
@@ -67,17 +65,15 @@ export async function createSkillContext(args: {
discoverOpencodeGlobalSkills(),
includeClaudeSkills ? discoverProjectClaudeSkills(directory) : Promise.resolve([]),
discoverOpencodeProjectSkills(directory),
discoverProjectAgentsSkills(directory),
discoverGlobalAgentsSkills(),
])
const mergedSkills = mergeSkills(
builtinSkills,
pluginConfig.skills,
configSourceSkills,
[...userSkills, ...agentsGlobalSkills],
userSkills,
globalSkills,
[...projectSkills, ...agentsProjectSkills],
projectSkills,
opencodeProjectSkills,
{ configDir: directory },
)

View File

@@ -0,0 +1,72 @@
/// <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")
})
})

View File

@@ -19,6 +19,7 @@ import {
createAstGrepTools,
createSessionManagerTools,
createDelegateTask,
createAgentTeamsTools,
discoverCommandsSync,
interactive_bash,
createTaskCreateTool,
@@ -117,6 +118,15 @@ 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),
@@ -132,6 +142,7 @@ export function createToolRegistry(args: {
slashcommand: slashcommandTool,
interactive_bash,
...taskToolsRecord,
...agentTeamsRecord,
}
const filteredTools = filterDisabledTools(allTools, pluginConfig.disabled_tools)

View File

@@ -1,103 +0,0 @@
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)
})
})

View File

@@ -1,9 +1,6 @@
import { lstatSync, realpathSync } from "fs"
import { lstatSync, readlinkSync } from "fs"
import { promises as fs } from "fs"
function normalizeDarwinRealpath(filePath: string): string {
return filePath.startsWith("/private/var/") ? filePath.slice("/private".length) : filePath
}
import { resolve } from "path"
export function isMarkdownFile(entry: { name: string; isFile: () => boolean }): boolean {
return !entry.name.startsWith(".") && entry.name.endsWith(".md") && entry.isFile()
@@ -19,7 +16,11 @@ export function isSymbolicLink(filePath: string): boolean {
export function resolveSymlink(filePath: string): string {
try {
return normalizeDarwinRealpath(realpathSync(filePath))
const stats = lstatSync(filePath, { throwIfNoEntry: false })
if (stats?.isSymbolicLink()) {
return resolve(filePath, "..", readlinkSync(filePath))
}
return filePath
} catch {
return filePath
}
@@ -27,7 +28,12 @@ export function resolveSymlink(filePath: string): string {
export async function resolveSymlinkAsync(filePath: string): Promise<string> {
try {
return normalizeDarwinRealpath(await fs.realpath(filePath))
const stats = await fs.lstat(filePath)
if (stats.isSymbolicLink()) {
const linkTarget = await fs.readlink(filePath)
return resolve(filePath, "..", linkTarget)
}
return filePath
} catch {
return filePath
}

View File

@@ -241,32 +241,19 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
expect(primary.providers[0]).toBe("openai")
})
test("visual-engineering has valid fallbackChain with gemini-3-pro high as primary", () => {
test("visual-engineering has valid fallbackChain with gemini-3-pro as primary", () => {
// given - visual-engineering category requirement
const visualEngineering = CATEGORY_MODEL_REQUIREMENTS["visual-engineering"]
// when - accessing visual-engineering requirement
// then - fallbackChain: gemini-3-pro(high) → glm-5 → opus-4-6(max) → k2p5
// then - fallbackChain exists with gemini-3-pro as first entry
expect(visualEngineering).toBeDefined()
expect(visualEngineering.fallbackChain).toBeArray()
expect(visualEngineering.fallbackChain).toHaveLength(4)
expect(visualEngineering.fallbackChain.length).toBeGreaterThan(0)
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", () => {
@@ -331,23 +318,19 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
expect(primary.providers[0]).toBe("google")
})
test("writing has valid fallbackChain with k2p5 as primary (kimi-for-coding)", () => {
test("writing has valid fallbackChain with gemini-3-flash as primary", () => {
// given - writing category requirement
const writing = CATEGORY_MODEL_REQUIREMENTS["writing"]
// when - accessing writing requirement
// then - fallbackChain: k2p5 → gemini-3-flash → claude-sonnet-4-5
// then - fallbackChain exists with gemini-3-flash as first entry
expect(writing).toBeDefined()
expect(writing.fallbackChain).toBeArray()
expect(writing.fallbackChain).toHaveLength(3)
expect(writing.fallbackChain.length).toBeGreaterThan(0)
const primary = writing.fallbackChain[0]
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")
expect(primary.model).toBe("gemini-3-flash")
expect(primary.providers[0]).toBe("google")
})
test("all 8 categories have valid fallbackChain arrays", () => {

View File

@@ -100,10 +100,9 @@ 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", variant: "high" },
{ providers: ["zai-coding-plan"], model: "glm-5" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["kimi-for-coding"], model: "k2p5" },
{ providers: ["zai-coding-plan"], model: "glm-4.7" },
],
},
ultrabrain: {
@@ -152,9 +151,10 @@ 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" },
],
},
}

View File

@@ -1,72 +0,0 @@
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 })
})
})

View File

@@ -1,14 +0,0 @@
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()
}

View File

@@ -0,0 +1,87 @@
/// <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")
})
})
})

View File

@@ -0,0 +1,24 @@
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" })
}
},
})
}

View File

@@ -0,0 +1,190 @@
/// <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)
})
})

View File

@@ -0,0 +1,61 @@
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)
}

View File

@@ -0,0 +1,59 @@
/// <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 }))
})
})

View File

@@ -0,0 +1,197 @@
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"

View File

@@ -0,0 +1,182 @@
/// <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")
})
})
})

View File

@@ -0,0 +1,29 @@
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" })
}
},
})
}

View File

@@ -0,0 +1 @@
export { createAgentTeamsTools } from "./tools"

View File

@@ -0,0 +1,467 @@
/// <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")
})
})
})

View File

@@ -0,0 +1,282 @@
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" })
}
},
})
}

View File

@@ -0,0 +1,79 @@
/// <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")
})
})

View File

@@ -0,0 +1,54 @@
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
}

View File

@@ -0,0 +1,81 @@
/// <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))
})
})

View File

@@ -0,0 +1,42 @@
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`)
}

View File

@@ -0,0 +1,214 @@
/// <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)
})
})

View File

@@ -0,0 +1,217 @@
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 []
}
}

View File

@@ -0,0 +1,297 @@
/// <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",
})
})
})
})

View File

@@ -0,0 +1,151 @@
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" })
}
},
})
}

View File

@@ -0,0 +1,94 @@
/// <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",
)
})
})

View File

@@ -0,0 +1,93 @@
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
}

View File

@@ -0,0 +1,460 @@
/// <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")
})
})

View File

@@ -0,0 +1,165 @@
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)
}

View File

@@ -0,0 +1,160 @@
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",
)
}

View File

@@ -0,0 +1,91 @@
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" })
}
},
})
}

View File

@@ -0,0 +1,247 @@
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)
})
}

View File

@@ -0,0 +1,243 @@
/// <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)
})
})
})

View File

@@ -0,0 +1,103 @@
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" })
}
},
})
}

View File

@@ -0,0 +1,36 @@
/// <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()
})
})

View File

@@ -0,0 +1,17 @@
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))
}

View File

@@ -0,0 +1,28 @@
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")
}

View File

@@ -0,0 +1,197 @@
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,
})
}

View File

@@ -0,0 +1,119 @@
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,
}
}

View File

@@ -0,0 +1,97 @@
/// <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)
})
})

View File

@@ -0,0 +1,198 @@
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

View File

@@ -0,0 +1,28 @@
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(),
}
}

View File

@@ -0,0 +1,721 @@
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