Compare commits

..

52 Commits

Author SHA1 Message Date
github-actions[bot]
18442a1637 release: v3.5.5 2026-02-15 05:48:47 +00:00
YeonGyu-Kim
d076187f0a test(cli): update model-fallback snapshots for kimi k2.5 and gemini-3-pro changes 2026-02-15 14:45:51 +09:00
YeonGyu-Kim
8a5f61724d fix(background-agent): handle message.part.delta for heartbeat (OpenCode >=1.2.0)
OpenCode 1.2.0+ changed reasoning-delta and text-delta to emit
'message.part.delta' instead of 'message.part.updated'. Without
handling this event, lastUpdate was only refreshed at reasoning-start
and reasoning-end, leaving a gap where extended thinking (>3min)
could trigger stale timeout.

Accept both event types as heartbeat sources for forward compatibility.
2026-02-15 14:26:25 +09:00
YeonGyu-Kim
3f557e593c fix(background-agent): use correct OpenCode session status for stale guard
OpenCode uses 'busy'/'retry'/'idle' session statuses, not 'running'.
The stale timeout guard checked for type === 'running' which never
matched, leaving all background tasks vulnerable to stale-kill even
when their sessions were actively processing.

Change sessionIsRunning to check type !== 'idle' instead, protecting
busy and retrying sessions from premature termination.
2026-02-15 14:24:45 +09:00
YeonGyu-Kim
284fafad11 feat(writing): switch primary model to kimi k2.5, add anti-AI-slop rules to prompt 2026-02-15 14:00:03 +09:00
YeonGyu-Kim
884a3addf8 feat(visual-engineering): add variant high to gemini-3-pro, update fallback chain to gemini→glm-5→opus→kimi 2026-02-15 13:59:00 +09:00
github-actions[bot]
c8172697d9 release: v3.5.4 2026-02-15 04:40:15 +00:00
YeonGyu-Kim
6dc8b7b875 fix(ci): sync publish.yml test steps with ci.yml to prevent mock pollution 2026-02-15 13:37:25 +09:00
github-actions[bot]
361d9a82d7 @iyoda has signed the CLA in code-yeongyu/oh-my-opencode#1845 2026-02-14 19:58:31 +00:00
github-actions[bot]
d8b4dba963 @liu-qingyuan has signed the CLA in code-yeongyu/oh-my-opencode#1844 2026-02-14 19:40:11 +00:00
YeonGyu-Kim
7b89df01a3 chore(schema): regenerate JSON schema 2026-02-14 22:07:05 +09:00
YeonGyu-Kim
dcb76f7efd test(directory-readme-injector): use real files instead of fs module mocks 2026-02-14 22:06:57 +09:00
YeonGyu-Kim
7b62f0c68b test(directory-agents-injector): use real files instead of fs module mocks 2026-02-14 22:06:52 +09:00
YeonGyu-Kim
2a7dfac50e test(skill-tool): restore bun mocks after tests 2026-02-14 22:06:46 +09:00
YeonGyu-Kim
2b4651e119 test(rules-injector): restore bun mocks after suite 2026-02-14 22:06:39 +09:00
YeonGyu-Kim
37d3086658 test(atlas): reset session state instead of module mocking 2026-02-14 22:06:34 +09:00
YeonGyu-Kim
e7dc3721df test(prometheus-md-only): avoid hook-message storage constant mocking 2026-02-14 22:06:28 +09:00
YeonGyu-Kim
e995443120 refactor(call-omo-agent): inject executeSync dependencies for tests 2026-02-14 22:06:23 +09:00
YeonGyu-Kim
3a690965fd test(todo-continuation-enforcer): stabilize fake timers 2026-02-14 22:06:18 +09:00
YeonGyu-Kim
74d2ae1023 fix(shared): normalize macOS realpath output 2026-02-14 22:06:13 +09:00
YeonGyu-Kim
a0c9381672 fix: prevent stale timeout from killing actively running background tasks
The stale detection was checking lastUpdate timestamps BEFORE
consulting session.status(), causing tasks to be unfairly killed
after 3 minutes even when the session was actively running
(e.g., during long tool executions or extended thinking).

Changes:
- Reorder pollRunningTasks to fetch session.status() before stale check
- Skip stale-kill entirely when session status is 'running'
- Port no-lastUpdate handling from task-poller.ts into manager.ts
  (previously manager silently skipped tasks without lastUpdate)
- Add sessionStatuses parameter to checkAndInterruptStaleTasks
- Add 7 new test cases covering session-status-aware stale detection
2026-02-14 17:59:01 +09:00
YeonGyu-Kim
65a06aa2b7 Merge pull request #1833 from code-yeongyu/fix/inherit-parent-session-tools
fix: inherit parent session tool restrictions in background task notifications
2026-02-14 15:01:37 +09:00
YeonGyu-Kim
754e6ee064 Merge pull request #1829 from code-yeongyu/fix/issue-1805-lsp-windows-binary
fix(lsp): remove unreliable Windows binary availability check
2026-02-14 15:01:35 +09:00
YeonGyu-Kim
affefee12f Merge pull request #1835 from code-yeongyu/fix/issue-1781-tmux-pane-width
fix(tmux): thread agent_pane_min_width config through pane management
2026-02-14 15:01:21 +09:00
YeonGyu-Kim
90463bafd2 Merge pull request #1834 from code-yeongyu/fix/issue-1818-agents-skills-path
fix(skill-loader): discover skills from .agents/skills/ directory
2026-02-14 15:01:18 +09:00
YeonGyu-Kim
073a074f8d Merge pull request #1828 from code-yeongyu/fix/issue-1825-run-never-exits
fix(cli-run): bounded shutdown wait for event stream processor
2026-02-14 15:01:16 +09:00
YeonGyu-Kim
cdda08cdb0 Merge pull request #1832 from code-yeongyu/fix/issue-1691-antigravity-error
fix: resilient error parsing for non-standard providers
2026-02-14 15:01:14 +09:00
YeonGyu-Kim
a8d26e3f74 Merge pull request #1831 from code-yeongyu/fix/issue-1701-load-skills-string
fix(delegate-task): parse load_skills when passed as JSON string
2026-02-14 15:01:12 +09:00
YeonGyu-Kim
8401f0a918 Merge pull request #1830 from code-yeongyu/fix/issue-980-zai-glm-thinking
fix: disable thinking params for Z.ai GLM models
2026-02-14 15:01:09 +09:00
YeonGyu-Kim
32470f5ca0 Merge pull request #1836 from code-yeongyu/fix/issue-1769-background-staleness
fix(background-agent): detect stale tasks that never received progress updates
2026-02-14 15:00:11 +09:00
github-actions[bot]
c3793f779b @code-yeongyu has signed the CLA in code-yeongyu/oh-my-opencode#1699 2026-02-14 05:59:47 +00:00
YeonGyu-Kim
3de05f6442 fix: apply parentTools in all parent session notification paths
Both parent-session-notifier.ts and notify-parent-session.ts now include
parentTools in the promptAsync body, ensuring tool restrictions are
consistently applied across all notification code paths.
2026-02-14 14:58:25 +09:00
YeonGyu-Kim
8514906c3d fix: inherit parent session tool restrictions in background task notifications
Pass parentTools from session-tools-store through the background task
lifecycle (launch → task → notify) so that when notifyParentSession
sends promptAsync, the original tool restrictions (e.g., question: false)
are preserved. This prevents the Question tool from re-enabling after
call_omo_agent background tasks complete.
2026-02-14 14:58:25 +09:00
YeonGyu-Kim
f20e1aa0d0 feat: store tool restrictions in session-tools-store at prompt-send sites
Call setSessionTools(sessionID, tools) before every prompt dispatch so
the tools object is captured and available for later retrieval when
background tasks complete.
2026-02-14 14:58:25 +09:00
YeonGyu-Kim
936b51de79 feat: add parentTools field to BackgroundTask, LaunchInput, ResumeInput
Allows background tasks to carry the parent session's tool restriction
map so it can be applied when notifying the parent session on completion.
2026-02-14 14:58:25 +09:00
YeonGyu-Kim
38a4bbc75f feat: add session-tools-store for tracking tool restrictions per session
In-memory Map-based store that records tool restriction objects (e.g.,
question: false) by sessionID when prompts are sent. This enables
retrieving the original session's tool parameters when background tasks
complete and need to notify the parent session.
2026-02-14 14:58:25 +09:00
YeonGyu-Kim
7186c368b9 fix(skill-loader): discover skills from .agents/skills/ directory
Add discoverProjectAgentsSkills() for project-level .agents/skills/ and
discoverGlobalAgentsSkills() for ~/.agents/skills/ — matching OpenCode's
native skill discovery paths (https://opencode.ai/docs/skills/).

Updated discoverAllSkills(), discoverSkills(), and createSkillContext()
to include these new sources with correct priority ordering.

Co-authored-by: dtateks <dtateks@users.noreply.github.com>
Closes #1818
2026-02-14 14:58:09 +09:00
YeonGyu-Kim
121a3c45c5 fix(tmux): thread agent_pane_min_width config through pane management
The agent_pane_min_width config value was accepted in the schema and
passed as CapacityConfig.agentPaneWidth but never actually used — the
underscore-prefixed _config parameter in decideSpawnActions was unused,
and all split/capacity calculations used the hardcoded MIN_PANE_WIDTH.

Now decideSpawnActions, canSplitPane, isSplittableAtCount,
findMinimalEvictions, and calculateCapacity all accept and use the
configured minimum pane width, falling back to the default (52) when
not provided.

Closes #1781
2026-02-14 14:58:07 +09:00
YeonGyu-Kim
072b30593e fix(parser): wrap parseAnthropicTokenLimitError in try/catch
Add outer try/catch to prevent crashes from non-standard error objects
returned by proxy providers (e.g., Antigravity). Add parser tests
covering edge cases: circular refs, non-object data fields, invalid
JSON in responseBody.
2026-02-14 14:58:06 +09:00
YeonGyu-Kim
dd9eeaa6d6 test(session-recovery): add tests for detect-error-type resilience
Add test coverage for detectErrorType and extractMessageIndex with
edge cases: circular references, non-standard proxy errors, null input.
Wrap both functions in try/catch to prevent crashes from malformed
error objects returned by non-standard providers like Antigravity.
2026-02-14 14:58:06 +09:00
YeonGyu-Kim
3fa543e851 fix(delegate-task): parse load_skills when passed as JSON string
LLMs sometimes pass load_skills as a serialized JSON string instead
of an array. Add defensive JSON.parse before validation to handle
this gracefully.

Fixes #1701

Community-reported-by: @omarmciver
2026-02-14 14:58:04 +09:00
YeonGyu-Kim
9f52e48e8f fix(think-mode): disable thinking parameter for Z.ai GLM models
Z.ai GLM models don't support thinking/reasoning parameters.
Ensure these are omitted entirely to prevent empty responses.

Fixes #980

Community-reported-by: @iserifith
2026-02-14 14:58:02 +09:00
YeonGyu-Kim
26ae666bc3 test(lsp): use explicit BDD markers in Windows spawn test 2026-02-14 14:58:01 +09:00
YeonGyu-Kim
422db236fe fix(lsp): remove unreliable Windows binary availability check
The isBinaryAvailableOnWindows() function used spawnSync("where")

which fails even when the binary IS on PATH, causing false negatives.

Removed the redundant pre-check and let nodeSpawn handle binary

resolution naturally with proper OS-level error messages.

Fixes #1805
2026-02-14 14:58:01 +09:00
YeonGyu-Kim
b7c32e8f50 fix(test): use string containment check for ANSI-wrapped console.log output
The waitForEventProcessorShutdown test was comparing exact string match
against console.log spy, but picocolors wraps the message in ANSI dim
codes. On CI (bun 1.3.9) this caused the assertion to fail. Use
string containment check instead of exact argument match.
2026-02-14 14:57:48 +09:00
YeonGyu-Kim
c24c4a85b4 fix(cli-run): bounded shutdown wait for event stream processor
Prevents Run CLI from hanging indefinitely when the event stream
fails to close after abort.

Fixes #1825

Co-authored-by: cloudwaddie-agent <cloudwaddie-agent@users.noreply.github.com>
2026-02-14 14:57:48 +09:00
YeonGyu-Kim
f3ff32fd18 fix(background-agent): detect stale tasks that never received progress updates
Tasks with no progress.lastUpdate were silently skipped in
checkAndInterruptStaleTasks, causing them to hang forever when the model
hangs before its first tool call. Now falls back to checking startedAt
against a configurable messageStalenessTimeoutMs (default: 10 minutes).

Closes #1769
2026-02-14 14:56:51 +09:00
YeonGyu-Kim
daf011c616 fix(ci): isolate loader.test.ts to prevent CWD deletion contamination
loader.test.ts creates and deletes temp directories via process.chdir()
which causes 'current working directory was deleted' errors for subsequent
tests running in the same process. Move it to isolated step and enumerate
remaining skill-loader test files individually.
2026-02-14 14:54:28 +09:00
YeonGyu-Kim
c8bc267127 fix(ci): isolate all mock-heavy test files from remaining test step
formatter.test.ts, format-default.test.ts, sync-executor.test.ts, and
session-creator.test.ts use mock.module() which pollutes bun's module
cache. Previously they ran both in the isolated step AND again in the
remaining tests step (via src/cli and src/tools wildcards), causing
cross-file contamination failures.

Now the remaining tests step enumerates subdirectories explicitly,
excluding the 4 mock-heavy files that are already run in isolation.
2026-02-14 14:39:53 +09:00
YeonGyu-Kim
c41b38990c ci: isolate mock-heavy tests to prevent cross-file module pollution
formatter.test.ts mocks format-default module, contaminating
format-default.test.ts. sync-executor.test.ts mocks session.create,
contaminating session-creator.test.ts. Run both in isolated processes.
2026-02-14 14:15:59 +09:00
YeonGyu-Kim
a4a5502e61 Merge pull request #1799 from bvanderhorn/fix/resolve-symlink-realpath
fix: use fs.realpath for symlink resolution (fixes #1738)
2026-02-14 13:46:04 +09:00
Bram van der Horn
1511886c0c fix: use fs.realpath instead of manual path.resolve for symlink resolution
resolveSymlink and resolveSymlinkAsync incorrectly resolved relative
symlinks by using path.resolve(filePath, '..', linkTarget). This fails
when symlinks use multi-level relative paths (e.g. ../../skills/...) or
when symlinks are chained (symlink pointing to a directory containing
more symlinks).

Replace with fs.realpathSync/fs.realpath which delegates to the OS for
correct resolution of all symlink types: relative, absolute, chained,
and nested.

Fixes #1738

AI-assisted-by: claude-opus-4.6 via opencode
AI-contribution: partial
AI-session: 20260212-120629-4gTXvDGV
2026-02-12 12:12:40 +01:00
127 changed files with 2462 additions and 8647 deletions

View File

@@ -52,12 +52,31 @@ jobs:
bun test src/hooks/atlas
bun test src/hooks/compaction-context-injector
bun test src/features/tmux-subagent
bun test src/cli/doctor/formatter.test.ts
bun test src/cli/doctor/format-default.test.ts
bun test src/tools/call-omo-agent/sync-executor.test.ts
bun test src/tools/call-omo-agent/session-creator.test.ts
bun test src/features/opencode-skill-loader/loader.test.ts
- name: Run remaining tests
run: |
# Run all other tests (mock-heavy ones are re-run but that's acceptable)
bun test bin script src/cli src/config src/mcp src/index.test.ts \
src/agents src/tools src/shared \
# Enumerate subdirectories/files explicitly to EXCLUDE mock-heavy files
# that were already run in isolation above.
# Excluded from src/cli: doctor/formatter.test.ts, doctor/format-default.test.ts
# Excluded from src/tools: call-omo-agent/sync-executor.test.ts, call-omo-agent/session-creator.test.ts
bun test bin script src/config src/mcp src/index.test.ts \
src/agents src/shared \
src/cli/run src/cli/config-manager src/cli/mcp-oauth \
src/cli/index.test.ts src/cli/install.test.ts src/cli/model-fallback.test.ts \
src/cli/config-manager.test.ts \
src/cli/doctor/runner.test.ts src/cli/doctor/checks \
src/tools/ast-grep src/tools/background-task src/tools/delegate-task \
src/tools/glob src/tools/grep src/tools/interactive-bash \
src/tools/look-at src/tools/lsp src/tools/session-manager \
src/tools/skill src/tools/skill-mcp src/tools/slashcommand src/tools/task \
src/tools/call-omo-agent/background-agent-executor.test.ts \
src/tools/call-omo-agent/background-executor.test.ts \
src/tools/call-omo-agent/subagent-session-creator.test.ts \
src/hooks/anthropic-context-window-limit-recovery \
src/hooks/claude-code-compatibility \
src/hooks/context-injection \
@@ -70,7 +89,11 @@ jobs:
src/features/builtin-skills \
src/features/claude-code-session-state \
src/features/hook-message-injector \
src/features/opencode-skill-loader \
src/features/opencode-skill-loader/config-source-discovery.test.ts \
src/features/opencode-skill-loader/merger.test.ts \
src/features/opencode-skill-loader/skill-content.test.ts \
src/features/opencode-skill-loader/blocking.test.ts \
src/features/opencode-skill-loader/async-loader.test.ts \
src/features/skill-mcp-manager
typecheck:

View File

@@ -51,13 +51,33 @@ jobs:
# Run them in separate processes to prevent cross-file contamination
bun test src/plugin-handlers
bun test src/hooks/atlas
bun test src/hooks/compaction-context-injector
bun test src/features/tmux-subagent
bun test src/cli/doctor/formatter.test.ts
bun test src/cli/doctor/format-default.test.ts
bun test src/tools/call-omo-agent/sync-executor.test.ts
bun test src/tools/call-omo-agent/session-creator.test.ts
bun test src/features/opencode-skill-loader/loader.test.ts
- name: Run remaining tests
run: |
# Run all other tests (mock-heavy ones are re-run but that's acceptable)
bun test bin script src/cli src/config src/mcp src/index.test.ts \
src/agents src/tools src/shared \
# Enumerate subdirectories/files explicitly to EXCLUDE mock-heavy files
# that were already run in isolation above.
# Excluded from src/cli: doctor/formatter.test.ts, doctor/format-default.test.ts
# Excluded from src/tools: call-omo-agent/sync-executor.test.ts, call-omo-agent/session-creator.test.ts
bun test bin script src/config src/mcp src/index.test.ts \
src/agents src/shared \
src/cli/run src/cli/config-manager src/cli/mcp-oauth \
src/cli/index.test.ts src/cli/install.test.ts src/cli/model-fallback.test.ts \
src/cli/config-manager.test.ts \
src/cli/doctor/runner.test.ts src/cli/doctor/checks \
src/tools/ast-grep src/tools/background-task src/tools/delegate-task \
src/tools/glob src/tools/grep src/tools/interactive-bash \
src/tools/look-at src/tools/lsp src/tools/session-manager \
src/tools/skill src/tools/skill-mcp src/tools/slashcommand src/tools/task \
src/tools/call-omo-agent/background-agent-executor.test.ts \
src/tools/call-omo-agent/background-executor.test.ts \
src/tools/call-omo-agent/subagent-session-creator.test.ts \
src/hooks/anthropic-context-window-limit-recovery \
src/hooks/claude-code-compatibility \
src/hooks/context-injection \
@@ -70,7 +90,11 @@ jobs:
src/features/builtin-skills \
src/features/claude-code-session-state \
src/features/hook-message-injector \
src/features/opencode-skill-loader \
src/features/opencode-skill-loader/config-source-discovery.test.ts \
src/features/opencode-skill-loader/merger.test.ts \
src/features/opencode-skill-loader/skill-content.test.ts \
src/features/opencode-skill-loader/blocking.test.ts \
src/features/opencode-skill-loader/async-loader.test.ts \
src/features/skill-mcp-manager
typecheck:

View File

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

View File

@@ -28,13 +28,13 @@
"typescript": "^5.7.3",
},
"optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.5.2",
"oh-my-opencode-darwin-x64": "3.5.2",
"oh-my-opencode-linux-arm64": "3.5.2",
"oh-my-opencode-linux-arm64-musl": "3.5.2",
"oh-my-opencode-linux-x64": "3.5.2",
"oh-my-opencode-linux-x64-musl": "3.5.2",
"oh-my-opencode-windows-x64": "3.5.2",
"oh-my-opencode-darwin-arm64": "3.5.3",
"oh-my-opencode-darwin-x64": "3.5.3",
"oh-my-opencode-linux-arm64": "3.5.3",
"oh-my-opencode-linux-arm64-musl": "3.5.3",
"oh-my-opencode-linux-x64": "3.5.3",
"oh-my-opencode-linux-x64-musl": "3.5.3",
"oh-my-opencode-windows-x64": "3.5.3",
},
},
},
@@ -226,19 +226,19 @@
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.5.2", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-oIS3lB2F9/N+3mF5wCKk6/EPVSz516XWN+mNdquSSeddw+xqMxGdhKY6K/XeYbHJzeN2Z8IOikNEJ6psR2/a8g=="],
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.5.3", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Dq0+PC2dyAqG7c3DUnQmdOkKbKmOsRHwoqgLCQNKN1lTRllF8zbWqp5B+LGKxSPxPqJIPS3mKt+wIR2KvkYJVw=="],
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.5.2", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-OAdXo4ZCCYO4kRWtnyz3tdmaGYPUB3WcXimXAxp+/sEZxAnh7n1RQkpLn6UxWX4AIAdRT9dfrOfRic6VoCYv2g=="],
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.5.3", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Ke45Bv/ygZm3YUSUumIyk647KZ2PFzw30tH597cOpG8MDPGbNVBCM6EKFezcukUPT+gPFVpE1IiGzEkn4JmgZA=="],
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.5.2", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-5XXNMFhp1VsyrGNRBoXcOyoaUeVkbrWkBRPDGZfpiq+kRXH3aaSWdR5G7Pl/TadOQv9Bl8/8YaxsuHRTFT1aXw=="],
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.5.3", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-aP5S3DngUhFkNeqYM33Ge6zccCWLzB/O3FLXLFXy/Iws03N8xugw72pnMK6lUbIia9QQBKK7IZBoYm9C79pZ3g=="],
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.5.2", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-/woIpqvEI85MgJvEVnz4g5FBLeiQNK7srRsueIFPBmtTahh42HFleCDaIltOl/ndjsE5nCHacQVJHkC9W9/F3Q=="],
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.5.3", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-UiD/hVKYZQyX4D5N5SnZT4M5Z/B2SDtJWBW4MibpYSAcPKNCEBKi/5E4hOPxAtTfFGR8tIXFmYZdQJDkVfvluw=="],
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.5.2", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-vTL2A+6zzGhi+m7sC8peLDq5OAp2dRR0UEb4RbZAOHtlEruF7qFEmcK3ccWxwc3+Z3G/ITfwn5VNa72ZS4pNTg=="],
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.5.3", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-L9kqwzElGkaQ8pgtv1ZjcHARw9LPaU4UEVjzauByTMi+/5Js/PTsNXBggxSRzZfQ8/MNBPSCiA4K10Kc0YjjvA=="],
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.5.2", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-bOAA55snLsK2QB00IkQy8le0Oqh/GJ7pxEHtm1oUezlQrW/nX5SS/hJ7dPHMmOd9FoiqnqyqWZxNkLmFoG463A=="],
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.5.3", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Z0fVVih/b2dbNeb9DK9oca5dNYCZyPySBRtxRhDXod5d7fJNgIPrvUoEd3SNfkRGORyFB3hGBZ6nqQ6N8+8DEA=="],
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.5.2", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-fnHiAPYglw3unPckmQBoCT6+VqjSWCE3S3J551mRo0ZFrxuEP2ZKyHZeFMMOtKwDepCvmKgd1W040+KmuVUXOA=="],
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.5.3", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-ocWPjRs2sJgN02PJnEIYtqdMVDex1YhEj1FzAU5XIicfzQbgxLh9nz1yhHZzfqGJq69QStU6ofpc5kQpfX1LMg=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode",
"version": "3.5.3",
"version": "3.5.5",
"description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -74,13 +74,13 @@
"typescript": "^5.7.3"
},
"optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.5.3",
"oh-my-opencode-darwin-x64": "3.5.3",
"oh-my-opencode-linux-arm64": "3.5.3",
"oh-my-opencode-linux-arm64-musl": "3.5.3",
"oh-my-opencode-linux-x64": "3.5.3",
"oh-my-opencode-linux-x64-musl": "3.5.3",
"oh-my-opencode-windows-x64": "3.5.3"
"oh-my-opencode-darwin-arm64": "3.5.5",
"oh-my-opencode-darwin-x64": "3.5.5",
"oh-my-opencode-linux-arm64": "3.5.5",
"oh-my-opencode-linux-arm64-musl": "3.5.5",
"oh-my-opencode-linux-x64": "3.5.5",
"oh-my-opencode-linux-x64-musl": "3.5.5",
"oh-my-opencode-windows-x64": "3.5.5"
},
"trustedDependencies": [
"@ast-grep/cli",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1471,6 +1471,38 @@
"created_at": "2026-02-14T04:15:19Z",
"repoId": 1108837393,
"pullRequestNo": 1827
},
{
"name": "morphaxl",
"id": 57144942,
"comment_id": 3872741516,
"created_at": "2026-02-09T16:21:56Z",
"repoId": 1108837393,
"pullRequestNo": 1699
},
{
"name": "morphaxl",
"id": 57144942,
"comment_id": 3872742242,
"created_at": "2026-02-09T16:22:04Z",
"repoId": 1108837393,
"pullRequestNo": 1699
},
{
"name": "liu-qingyuan",
"id": 57737268,
"comment_id": 3902402078,
"created_at": "2026-02-14T19:39:58Z",
"repoId": 1108837393,
"pullRequestNo": 1844
},
{
"name": "iyoda",
"id": 31020,
"comment_id": 3902426789,
"created_at": "2026-02-14T19:58:19Z",
"repoId": 1108837393,
"pullRequestNo": 1845
}
]
}

View File

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

View File

@@ -1,6 +1,8 @@
import { describe, it, expect } from "bun:test"
/// <reference types="bun-types" />
import { describe, it, expect, spyOn, afterEach } from "bun:test"
import type { OhMyOpenCodeConfig } from "../../config"
import { resolveRunAgent } from "./runner"
import { resolveRunAgent, waitForEventProcessorShutdown } from "./runner"
const createConfig = (overrides: Partial<OhMyOpenCodeConfig> = {}): OhMyOpenCodeConfig => ({
...overrides,
@@ -68,3 +70,59 @@ describe("resolveRunAgent", () => {
expect(agent).toBe("hephaestus")
})
})
describe("waitForEventProcessorShutdown", () => {
let consoleLogSpy: ReturnType<typeof spyOn<typeof console, "log">> | null = null
afterEach(() => {
if (consoleLogSpy) {
consoleLogSpy.mockRestore()
consoleLogSpy = null
}
})
it("returns quickly when event processor completes", async () => {
//#given
const eventProcessor = new Promise<void>((resolve) => {
setTimeout(() => {
resolve()
}, 25)
})
consoleLogSpy = spyOn(console, "log").mockImplementation(() => {})
const start = performance.now()
//#when
await waitForEventProcessorShutdown(eventProcessor, 200)
//#then
const elapsed = performance.now() - start
expect(elapsed).toBeLessThan(200)
expect(console.log).not.toHaveBeenCalledWith(
"[run] Event stream did not close within 200ms after abort; continuing shutdown.",
)
})
it("times out and continues when event processor does not complete", async () => {
//#given
const eventProcessor = new Promise<void>(() => {})
const spy = spyOn(console, "log").mockImplementation(() => {})
consoleLogSpy = spy
const timeoutMs = 50
const start = performance.now()
try {
//#when
await waitForEventProcessorShutdown(eventProcessor, timeoutMs)
//#then
const elapsed = performance.now() - start
expect(elapsed).toBeGreaterThanOrEqual(timeoutMs)
const callArgs = spy.mock.calls.flat().join("")
expect(callArgs).toContain(
`[run] Event stream did not close within ${timeoutMs}ms after abort; continuing shutdown.`,
)
} finally {
spy.mockRestore()
}
})
})

View File

@@ -12,6 +12,25 @@ import { pollForCompletion } from "./poll-for-completion"
export { resolveRunAgent }
const DEFAULT_TIMEOUT_MS = 600_000
const EVENT_PROCESSOR_SHUTDOWN_TIMEOUT_MS = 2_000
export async function waitForEventProcessorShutdown(
eventProcessor: Promise<void>,
timeoutMs = EVENT_PROCESSOR_SHUTDOWN_TIMEOUT_MS,
): Promise<void> {
const completed = await Promise.race([
eventProcessor.then(() => true),
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), timeoutMs)),
])
if (!completed) {
console.log(
pc.dim(
`[run] Event stream did not close within ${timeoutMs}ms after abort; continuing shutdown.`,
),
)
}
}
export async function run(options: RunOptions): Promise<number> {
process.env.OPENCODE_CLI_RUN_MODE = "true"
@@ -81,14 +100,14 @@ export async function run(options: RunOptions): Promise<number> {
query: { directory },
})
console.log(pc.dim("Waiting for completion...\n"))
const exitCode = await pollForCompletion(ctx, eventState, abortController)
console.log(pc.dim("Waiting for completion...\n"))
const exitCode = await pollForCompletion(ctx, eventState, abortController)
// Abort the event stream to stop the processor
abortController.abort()
// Abort the event stream to stop the processor
abortController.abort()
await eventProcessor
cleanup()
await waitForEventProcessorShutdown(eventProcessor)
cleanup()
const durationMs = Date.now() - startTime
@@ -127,4 +146,3 @@ export async function run(options: RunOptions): Promise<number> {
return 1
}
}

View File

@@ -649,21 +649,7 @@ describe("ExperimentalConfigSchema feature flags", () => {
}
})
test("accepts team_system as boolean", () => {
//#given
const config = { team_system: true }
//#when
const result = ExperimentalConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.team_system).toBe(true)
}
})
test("defaults team_system to false when not provided", () => {
test("both fields are optional", () => {
//#given
const config = {}
@@ -673,34 +659,10 @@ describe("ExperimentalConfigSchema feature flags", () => {
//#then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.team_system).toBe(false)
expect(result.data.plugin_load_timeout_ms).toBeUndefined()
expect(result.data.safe_hook_creation).toBeUndefined()
}
})
test("accepts team_system as false", () => {
//#given
const config = { team_system: false }
//#when
const result = ExperimentalConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.team_system).toBe(false)
}
})
test("rejects non-boolean team_system", () => {
//#given
const config = { team_system: "true" }
//#when
const result = ExperimentalConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(false)
})
})
describe("GitMasterConfigSchema", () => {

View File

@@ -6,6 +6,8 @@ export const BackgroundTaskConfigSchema = z.object({
modelConcurrency: z.record(z.string(), z.number().min(0)).optional(),
/** Stale timeout in milliseconds - interrupt tasks with no activity for this duration (default: 180000 = 3 minutes, minimum: 60000 = 1 minute) */
staleTimeoutMs: z.number().min(60000).optional(),
/** Timeout for tasks that never received any progress update, falling back to startedAt (default: 600000 = 10 minutes, minimum: 60000 = 1 minute) */
messageStalenessTimeoutMs: z.number().min(60000).optional(),
})
export type BackgroundTaskConfig = z.infer<typeof BackgroundTaskConfigSchema>

View File

@@ -15,10 +15,6 @@ export const ExperimentalConfigSchema = z.object({
plugin_load_timeout_ms: z.number().min(1000).optional(),
/** Wrap hook creation in try/catch to prevent one failing hook from crashing the plugin (default: true at call site) */
safe_hook_creation: z.boolean().optional(),
/** Enable experimental agent teams toolset (default: false) */
agent_teams: z.boolean().optional(),
/** Enable experimental team system (default: false) */
team_system: z.boolean().default(false),
})
export type ExperimentalConfig = z.infer<typeof ExperimentalConfigSchema>

View File

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

View File

@@ -4,6 +4,7 @@ import type { BackgroundTask, LaunchInput } from "./types"
export const TASK_TTL_MS = 30 * 60 * 1000
export const MIN_STABILITY_TIME_MS = 10 * 1000
export const DEFAULT_STALE_TIMEOUT_MS = 180_000
export const DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS = 600_000
export const MIN_RUNTIME_BEFORE_STALE_MS = 30_000
export const MIN_IDLE_TIME_MS = 5000
export const POLLING_INTERVAL_MS = 3000

View File

@@ -2289,10 +2289,221 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
getTaskMap(manager).set(task.id, task)
await manager["checkAndInterruptStaleTasks"]()
await manager["checkAndInterruptStaleTasks"]()
expect(task.status).toBe("cancelled")
})
test("should NOT interrupt task when session is running, even with stale lastUpdate", async () => {
//#given
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
const task: BackgroundTask = {
id: "task-running-session",
sessionID: "session-running",
parentSessionID: "parent-rs",
parentMessageID: "msg-rs",
description: "Task with running session",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 300_000),
progress: {
toolCalls: 2,
lastUpdate: new Date(Date.now() - 300_000),
},
}
getTaskMap(manager).set(task.id, task)
//#when — session is actively running
await manager["checkAndInterruptStaleTasks"]({ "session-running": { type: "running" } })
//#then — task survives because session is running
expect(task.status).toBe("running")
})
test("should interrupt task when session is idle and lastUpdate exceeds stale timeout", async () => {
//#given
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
stubNotifyParentSession(manager)
const task: BackgroundTask = {
id: "task-idle-session",
sessionID: "session-idle",
parentSessionID: "parent-is",
parentMessageID: "msg-is",
description: "Task with idle session",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 300_000),
progress: {
toolCalls: 2,
lastUpdate: new Date(Date.now() - 300_000),
},
}
getTaskMap(manager).set(task.id, task)
//#when — session is idle
await manager["checkAndInterruptStaleTasks"]({ "session-idle": { type: "idle" } })
//#then — killed because session is idle with stale lastUpdate
expect(task.status).toBe("cancelled")
expect(task.error).toContain("Stale timeout")
})
test("should NOT interrupt running session even with very old lastUpdate (no safety net)", async () => {
//#given
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
const task: BackgroundTask = {
id: "task-long-running",
sessionID: "session-long",
parentSessionID: "parent-lr",
parentMessageID: "msg-lr",
description: "Long running task",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 900_000),
progress: {
toolCalls: 5,
lastUpdate: new Date(Date.now() - 900_000),
},
}
getTaskMap(manager).set(task.id, task)
//#when — session is running, lastUpdate 15min old
await manager["checkAndInterruptStaleTasks"]({ "session-long": { type: "running" } })
//#then — running sessions are NEVER stale-killed
expect(task.status).toBe("running")
})
test("should NOT interrupt running session with no progress (undefined lastUpdate)", async () => {
//#given — no progress at all, but session is running
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { messageStalenessTimeoutMs: 600_000 })
const task: BackgroundTask = {
id: "task-running-no-progress",
sessionID: "session-rnp",
parentSessionID: "parent-rnp",
parentMessageID: "msg-rnp",
description: "Running no progress",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 15 * 60 * 1000),
progress: undefined,
}
getTaskMap(manager).set(task.id, task)
//#when — session is running despite no progress
await manager["checkAndInterruptStaleTasks"]({ "session-rnp": { type: "running" } })
//#then — running sessions are NEVER killed
expect(task.status).toBe("running")
})
test("should interrupt task with no lastUpdate after messageStalenessTimeout", async () => {
//#given
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { messageStalenessTimeoutMs: 600_000 })
stubNotifyParentSession(manager)
const task: BackgroundTask = {
id: "task-no-update",
sessionID: "session-no-update",
parentSessionID: "parent-nu",
parentMessageID: "msg-nu",
description: "No update task",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 15 * 60 * 1000),
progress: undefined,
}
getTaskMap(manager).set(task.id, task)
//#when — no progress update for 15 minutes
await manager["checkAndInterruptStaleTasks"]({})
//#then — killed after messageStalenessTimeout
expect(task.status).toBe("cancelled")
expect(task.error).toContain("no activity")
})
test("should NOT interrupt task with no lastUpdate within messageStalenessTimeout", async () => {
//#given
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { messageStalenessTimeoutMs: 600_000 })
const task: BackgroundTask = {
id: "task-fresh-no-update",
sessionID: "session-fresh",
parentSessionID: "parent-fn",
parentMessageID: "msg-fn",
description: "Fresh no-update task",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 5 * 60 * 1000),
progress: undefined,
}
getTaskMap(manager).set(task.id, task)
//#when — only 5 min since start, within 10min timeout
await manager["checkAndInterruptStaleTasks"]({})
//#then — task survives
expect(task.status).toBe("running")
})
})
describe("BackgroundManager.shutdown session abort", () => {
@@ -3202,4 +3413,44 @@ describe("BackgroundManager.handleEvent - non-tool event lastUpdate", () => {
//#then - task should still be running (text event refreshed lastUpdate)
expect(task.status).toBe("running")
})
test("should refresh lastUpdate on message.part.delta events (OpenCode >=1.2.0)", async () => {
//#given - a running task with stale lastUpdate
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
stubNotifyParentSession(manager)
const task: BackgroundTask = {
id: "task-delta-1",
sessionID: "session-delta-1",
parentSessionID: "parent-1",
parentMessageID: "msg-1",
description: "Reasoning task with delta events",
prompt: "Extended thinking",
agent: "oracle",
status: "running",
startedAt: new Date(Date.now() - 600_000),
progress: {
toolCalls: 0,
lastUpdate: new Date(Date.now() - 300_000),
},
}
getTaskMap(manager).set(task.id, task)
//#when - a message.part.delta event arrives (reasoning-delta or text-delta in OpenCode >=1.2.0)
manager.handleEvent({
type: "message.part.delta",
properties: { sessionID: "session-delta-1", field: "text", delta: "thinking..." },
})
await manager["checkAndInterruptStaleTasks"]()
//#then - task should still be running (delta event refreshed lastUpdate)
expect(task.status).toBe("running")
})
})

View File

@@ -7,10 +7,12 @@ import type {
} from "./types"
import { TaskHistory } from "./task-history"
import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry } from "../../shared"
import { setSessionTools } from "../../shared/session-tools-store"
import { ConcurrencyManager } from "./concurrency"
import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema"
import { isInsideTmux } from "../../shared/tmux"
import {
DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS,
DEFAULT_STALE_TIMEOUT_MS,
MIN_IDLE_TIME_MS,
MIN_RUNTIME_BEFORE_STALE_MS,
@@ -141,6 +143,7 @@ export class BackgroundManager {
parentMessageID: input.parentMessageID,
parentModel: input.parentModel,
parentAgent: input.parentAgent,
parentTools: input.parentTools,
model: input.model,
category: input.category,
}
@@ -328,12 +331,16 @@ export class BackgroundManager {
...(launchModel ? { model: launchModel } : {}),
...(launchVariant ? { variant: launchVariant } : {}),
system: input.skillContent,
tools: {
...getAgentToolRestrictions(input.agent),
task: false,
call_omo_agent: true,
question: false,
},
tools: (() => {
const tools = {
...getAgentToolRestrictions(input.agent),
task: false,
call_omo_agent: true,
question: false,
}
setSessionTools(sessionID, tools)
return tools
})(),
parts: [{ type: "text", text: input.prompt }],
},
}).catch((error) => {
@@ -535,6 +542,9 @@ export class BackgroundManager {
existingTask.parentMessageID = input.parentMessageID
existingTask.parentModel = input.parentModel
existingTask.parentAgent = input.parentAgent
if (input.parentTools) {
existingTask.parentTools = input.parentTools
}
// Reset startedAt on resume to prevent immediate completion
// The MIN_IDLE_TIME_MS check uses startedAt, so resumed tasks need fresh timing
existingTask.startedAt = new Date()
@@ -588,12 +598,16 @@ export class BackgroundManager {
agent: existingTask.agent,
...(resumeModel ? { model: resumeModel } : {}),
...(resumeVariant ? { variant: resumeVariant } : {}),
tools: {
...getAgentToolRestrictions(existingTask.agent),
task: false,
call_omo_agent: true,
question: false,
},
tools: (() => {
const tools = {
...getAgentToolRestrictions(existingTask.agent),
task: false,
call_omo_agent: true,
question: false,
}
setSessionTools(existingTask.sessionID!, tools)
return tools
})(),
parts: [{ type: "text", text: input.prompt }],
},
}).catch((error) => {
@@ -646,7 +660,7 @@ export class BackgroundManager {
handleEvent(event: Event): void {
const props = event.properties
if (event.type === "message.part.updated") {
if (event.type === "message.part.updated" || event.type === "message.part.delta") {
if (!props || typeof props !== "object" || !("sessionID" in props)) return
const partInfo = props as unknown as MessagePartInfo
const sessionID = partInfo?.sessionID
@@ -1252,6 +1266,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
noReply: !allComplete,
...(agent !== undefined ? { agent } : {}),
...(model !== undefined ? { model } : {}),
...(task.parentTools ? { tools: task.parentTools } : {}),
parts: [{ type: "text", text: notification }],
},
})
@@ -1423,24 +1438,55 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
}
}
private async checkAndInterruptStaleTasks(): Promise<void> {
private async checkAndInterruptStaleTasks(
allStatuses: Record<string, { type: string }> = {},
): Promise<void> {
const staleTimeoutMs = this.config?.staleTimeoutMs ?? DEFAULT_STALE_TIMEOUT_MS
const messageStalenessMs = this.config?.messageStalenessTimeoutMs ?? DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS
const now = Date.now()
for (const task of this.tasks.values()) {
if (task.status !== "running") continue
if (!task.progress?.lastUpdate) continue
const startedAt = task.startedAt
const sessionID = task.sessionID
if (!startedAt || !sessionID) continue
const sessionStatus = allStatuses[sessionID]?.type
const sessionIsRunning = sessionStatus !== undefined && sessionStatus !== "idle"
const runtime = now - startedAt.getTime()
if (!task.progress?.lastUpdate) {
if (sessionIsRunning) continue
if (runtime <= messageStalenessMs) continue
const staleMinutes = Math.round(runtime / 60000)
task.status = "cancelled"
task.error = `Stale timeout (no activity for ${staleMinutes}min since start)`
task.completedAt = new Date()
if (task.concurrencyKey) {
this.concurrencyManager.release(task.concurrencyKey)
task.concurrencyKey = undefined
}
this.client.session.abort({ path: { id: sessionID } }).catch(() => {})
log(`[background-agent] Task ${task.id} interrupted: no progress since start`)
try {
await this.enqueueNotificationForParent(task.parentSessionID, () => this.notifyParentSession(task))
} catch (err) {
log("[background-agent] Error in notifyParentSession for stale task:", { taskId: task.id, error: err })
}
continue
}
if (sessionIsRunning) continue
if (runtime < MIN_RUNTIME_BEFORE_STALE_MS) continue
const timeSinceLastUpdate = now - task.progress.lastUpdate.getTime()
if (timeSinceLastUpdate <= staleTimeoutMs) continue
if (task.status !== "running") continue
const staleMinutes = Math.round(timeSinceLastUpdate / 60000)
@@ -1453,10 +1499,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
task.concurrencyKey = undefined
}
this.client.session.abort({
path: { id: sessionID },
}).catch(() => {})
this.client.session.abort({ path: { id: sessionID } }).catch(() => {})
log(`[background-agent] Task ${task.id} interrupted: stale timeout`)
try {
@@ -1469,11 +1512,12 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
private async pollRunningTasks(): Promise<void> {
this.pruneStaleTasksAndNotifications()
await this.checkAndInterruptStaleTasks()
const statusResult = await this.client.session.status()
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>
await this.checkAndInterruptStaleTasks(allStatuses)
for (const task of this.tasks.values()) {
if (task.status !== "running") continue
@@ -1483,7 +1527,6 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
try {
const sessionStatus = allStatuses[sessionID]
// Don't skip if session not in status - fall through to message-based detection
if (sessionStatus?.type === "idle") {
// Edge guard: Validate session has actual output before completing
const hasValidOutput = await this.validateSessionHasOutput(sessionID)

View File

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

View File

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

View File

@@ -34,7 +34,7 @@ export async function pollRunningTasks(args: {
tasks: Iterable<BackgroundTask>
client: OpencodeClient
pruneStaleTasksAndNotifications: () => void
checkAndInterruptStaleTasks: () => Promise<void>
checkAndInterruptStaleTasks: (statuses: Record<string, { type: string }>) => Promise<void>
validateSessionHasOutput: (sessionID: string) => Promise<boolean>
checkSessionTodos: (sessionID: string) => Promise<boolean>
tryCompleteTask: (task: BackgroundTask, source: string) => Promise<boolean>
@@ -54,11 +54,12 @@ export async function pollRunningTasks(args: {
} = args
pruneStaleTasksAndNotifications()
await checkAndInterruptStaleTasks()
const statusResult = await client.session.status()
const allStatuses = ((statusResult as { data?: unknown }).data ?? {}) as SessionStatusMap
await checkAndInterruptStaleTasks(allStatuses)
for (const task of tasks) {
if (task.status !== "running") continue

View File

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

View File

@@ -1,5 +1,6 @@
import type { BackgroundTask, ResumeInput } from "../types"
import { log, getAgentToolRestrictions } from "../../../shared"
import { setSessionTools } from "../../../shared/session-tools-store"
import type { SpawnerContext } from "./spawner-context"
import { subagentSessions } from "../../claude-code-session-state"
import { getTaskToastManager } from "../../task-toast-manager"
@@ -35,6 +36,9 @@ export async function resumeTask(
task.parentMessageID = input.parentMessageID
task.parentModel = input.parentModel
task.parentAgent = input.parentAgent
if (input.parentTools) {
task.parentTools = input.parentTools
}
task.startedAt = new Date()
task.progress = {
@@ -75,12 +79,16 @@ export async function resumeTask(
agent: task.agent,
...(resumeModel ? { model: resumeModel } : {}),
...(resumeVariant ? { variant: resumeVariant } : {}),
tools: {
...getAgentToolRestrictions(task.agent),
task: false,
call_omo_agent: true,
question: false,
},
tools: (() => {
const tools = {
...getAgentToolRestrictions(task.agent),
task: false,
call_omo_agent: true,
question: false,
}
setSessionTools(task.sessionID!, tools)
return tools
})(),
parts: [{ type: "text", text: input.prompt }],
},
})

View File

@@ -1,5 +1,6 @@
import type { QueueItem } from "../constants"
import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry } from "../../../shared"
import { setSessionTools } from "../../../shared/session-tools-store"
import { subagentSessions } from "../../claude-code-session-state"
import { getTaskToastManager } from "../../task-toast-manager"
import { createBackgroundSession } from "./background-session-creator"
@@ -79,12 +80,16 @@ export async function startTask(item: QueueItem, ctx: SpawnerContext): Promise<v
...(launchModel ? { model: launchModel } : {}),
...(launchVariant ? { variant: launchVariant } : {}),
system: input.skillContent,
tools: {
...getAgentToolRestrictions(input.agent),
task: false,
call_omo_agent: true,
question: false,
},
tools: (() => {
const tools = {
...getAgentToolRestrictions(input.agent),
task: false,
call_omo_agent: true,
question: false,
}
setSessionTools(sessionID, tools)
return tools
})(),
parts: [{ type: "text", text: input.prompt }],
},
}).catch((error: unknown) => {

View File

@@ -0,0 +1,425 @@
import { describe, it, expect, mock } from "bun:test"
import { checkAndInterruptStaleTasks, pruneStaleTasksAndNotifications } from "./task-poller"
import type { BackgroundTask } from "./types"
describe("checkAndInterruptStaleTasks", () => {
const mockClient = {
session: {
abort: mock(() => Promise.resolve()),
},
}
const mockConcurrencyManager = {
release: mock(() => {}),
}
const mockNotify = mock(() => Promise.resolve())
function createRunningTask(overrides: Partial<BackgroundTask> = {}): BackgroundTask {
return {
id: "task-1",
sessionID: "ses-1",
parentSessionID: "parent-ses-1",
parentMessageID: "msg-1",
description: "test",
prompt: "test",
agent: "explore",
status: "running",
startedAt: new Date(Date.now() - 120_000),
...overrides,
}
}
it("should interrupt tasks with lastUpdate exceeding stale timeout", async () => {
//#given
const task = createRunningTask({
progress: {
toolCalls: 1,
lastUpdate: new Date(Date.now() - 200_000),
},
})
//#when
await checkAndInterruptStaleTasks({
tasks: [task],
client: mockClient as never,
config: { staleTimeoutMs: 180_000 },
concurrencyManager: mockConcurrencyManager as never,
notifyParentSession: mockNotify,
})
//#then
expect(task.status).toBe("cancelled")
expect(task.error).toContain("Stale timeout")
})
it("should NOT interrupt tasks with recent lastUpdate", async () => {
//#given
const task = createRunningTask({
progress: {
toolCalls: 1,
lastUpdate: new Date(Date.now() - 10_000),
},
})
//#when
await checkAndInterruptStaleTasks({
tasks: [task],
client: mockClient as never,
config: { staleTimeoutMs: 180_000 },
concurrencyManager: mockConcurrencyManager as never,
notifyParentSession: mockNotify,
})
//#then
expect(task.status).toBe("running")
})
it("should interrupt tasks with NO progress.lastUpdate that exceeded messageStalenessTimeoutMs since startedAt", async () => {
//#given — task started 15 minutes ago, never received any progress update
const task = createRunningTask({
startedAt: new Date(Date.now() - 15 * 60 * 1000),
progress: undefined,
})
//#when
await checkAndInterruptStaleTasks({
tasks: [task],
client: mockClient as never,
config: { messageStalenessTimeoutMs: 600_000 },
concurrencyManager: mockConcurrencyManager as never,
notifyParentSession: mockNotify,
})
//#then
expect(task.status).toBe("cancelled")
expect(task.error).toContain("no activity")
})
it("should NOT interrupt tasks with NO progress.lastUpdate that are within messageStalenessTimeoutMs", async () => {
//#given — task started 5 minutes ago, default timeout is 10 minutes
const task = createRunningTask({
startedAt: new Date(Date.now() - 5 * 60 * 1000),
progress: undefined,
})
//#when
await checkAndInterruptStaleTasks({
tasks: [task],
client: mockClient as never,
config: { messageStalenessTimeoutMs: 600_000 },
concurrencyManager: mockConcurrencyManager as never,
notifyParentSession: mockNotify,
})
//#then
expect(task.status).toBe("running")
})
it("should use DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS when messageStalenessTimeoutMs is not configured", async () => {
//#given — task started 15 minutes ago, no config for messageStalenessTimeoutMs
const task = createRunningTask({
startedAt: new Date(Date.now() - 15 * 60 * 1000),
progress: undefined,
})
//#when — default is 10 minutes (600_000ms)
await checkAndInterruptStaleTasks({
tasks: [task],
client: mockClient as never,
config: undefined,
concurrencyManager: mockConcurrencyManager as never,
notifyParentSession: mockNotify,
})
//#then
expect(task.status).toBe("cancelled")
expect(task.error).toContain("no activity")
})
it("should NOT interrupt task when session is running, even if lastUpdate exceeds stale timeout", async () => {
//#given — lastUpdate is 5min old but session is actively running
const task = createRunningTask({
startedAt: new Date(Date.now() - 300_000),
progress: {
toolCalls: 2,
lastUpdate: new Date(Date.now() - 300_000),
},
})
//#when — session status is "busy" (OpenCode's actual status for active LLM processing)
await checkAndInterruptStaleTasks({
tasks: [task],
client: mockClient as never,
config: { staleTimeoutMs: 180_000 },
concurrencyManager: mockConcurrencyManager as never,
notifyParentSession: mockNotify,
sessionStatuses: { "ses-1": { type: "busy" } },
})
//#then — task should survive because session is actively busy
expect(task.status).toBe("running")
})
it("should NOT interrupt busy session task even with very old lastUpdate", async () => {
//#given — lastUpdate is 15min old, but session is still busy
const task = createRunningTask({
startedAt: new Date(Date.now() - 900_000),
progress: {
toolCalls: 2,
lastUpdate: new Date(Date.now() - 900_000),
},
})
//#when — session busy, lastUpdate far exceeds any timeout
await checkAndInterruptStaleTasks({
tasks: [task],
client: mockClient as never,
config: { staleTimeoutMs: 180_000, messageStalenessTimeoutMs: 600_000 },
concurrencyManager: mockConcurrencyManager as never,
notifyParentSession: mockNotify,
sessionStatuses: { "ses-1": { type: "busy" } },
})
//#then — busy sessions are NEVER stale-killed (babysitter + TTL prune handle these)
expect(task.status).toBe("running")
})
it("should NOT interrupt busy session even with no progress (undefined lastUpdate)", async () => {
//#given — task has no progress at all, but session is busy
const task = createRunningTask({
startedAt: new Date(Date.now() - 15 * 60 * 1000),
progress: undefined,
})
//#when — session is busy
await checkAndInterruptStaleTasks({
tasks: [task],
client: mockClient as never,
config: { messageStalenessTimeoutMs: 600_000 },
concurrencyManager: mockConcurrencyManager as never,
notifyParentSession: mockNotify,
sessionStatuses: { "ses-1": { type: "busy" } },
})
//#then — task should survive because session is actively running
expect(task.status).toBe("running")
})
it("should interrupt task when session is idle and lastUpdate exceeds stale timeout", async () => {
//#given — lastUpdate is 5min old and session is idle
const task = createRunningTask({
startedAt: new Date(Date.now() - 300_000),
progress: {
toolCalls: 2,
lastUpdate: new Date(Date.now() - 300_000),
},
})
//#when — session status is "idle"
await checkAndInterruptStaleTasks({
tasks: [task],
client: mockClient as never,
config: { staleTimeoutMs: 180_000 },
concurrencyManager: mockConcurrencyManager as never,
notifyParentSession: mockNotify,
sessionStatuses: { "ses-1": { type: "idle" } },
})
//#then — task should be killed because session is idle with stale lastUpdate
expect(task.status).toBe("cancelled")
expect(task.error).toContain("Stale timeout")
})
it("should NOT interrupt running session task even with very old lastUpdate", async () => {
//#given — lastUpdate is 15min old, but session is still running
const task = createRunningTask({
startedAt: new Date(Date.now() - 900_000),
progress: {
toolCalls: 2,
lastUpdate: new Date(Date.now() - 900_000),
},
})
//#when — session running, lastUpdate far exceeds any timeout
await checkAndInterruptStaleTasks({
tasks: [task],
client: mockClient as never,
config: { staleTimeoutMs: 180_000, messageStalenessTimeoutMs: 600_000 },
concurrencyManager: mockConcurrencyManager as never,
notifyParentSession: mockNotify,
sessionStatuses: { "ses-1": { type: "running" } },
})
//#then — running sessions are NEVER stale-killed (babysitter + TTL prune handle these)
expect(task.status).toBe("running")
})
it("should NOT interrupt running session even with no progress (undefined lastUpdate)", async () => {
//#given — task has no progress at all, but session is running
const task = createRunningTask({
startedAt: new Date(Date.now() - 15 * 60 * 1000),
progress: undefined,
})
//#when — session is running
await checkAndInterruptStaleTasks({
tasks: [task],
client: mockClient as never,
config: { messageStalenessTimeoutMs: 600_000 },
concurrencyManager: mockConcurrencyManager as never,
notifyParentSession: mockNotify,
sessionStatuses: { "ses-1": { type: "running" } },
})
//#then — running sessions are NEVER killed, even without progress
expect(task.status).toBe("running")
})
it("should use default stale timeout when session status is unknown/missing", async () => {
//#given — lastUpdate exceeds stale timeout, session not in status map
const task = createRunningTask({
startedAt: new Date(Date.now() - 300_000),
progress: {
toolCalls: 1,
lastUpdate: new Date(Date.now() - 200_000),
},
})
//#when — empty sessionStatuses (session not found)
await checkAndInterruptStaleTasks({
tasks: [task],
client: mockClient as never,
config: { staleTimeoutMs: 180_000 },
concurrencyManager: mockConcurrencyManager as never,
notifyParentSession: mockNotify,
sessionStatuses: {},
})
//#then — unknown session treated as potentially stale, apply default timeout
expect(task.status).toBe("cancelled")
expect(task.error).toContain("Stale timeout")
})
it("should NOT interrupt task when session is busy (OpenCode status), even if lastUpdate exceeds stale timeout", async () => {
//#given — lastUpdate is 5min old but session is "busy" (OpenCode's actual status for active sessions)
const task = createRunningTask({
startedAt: new Date(Date.now() - 300_000),
progress: {
toolCalls: 2,
lastUpdate: new Date(Date.now() - 300_000),
},
})
//#when — session status is "busy" (not "running" — OpenCode uses "busy" for active LLM processing)
await checkAndInterruptStaleTasks({
tasks: [task],
client: mockClient as never,
config: { staleTimeoutMs: 180_000 },
concurrencyManager: mockConcurrencyManager as never,
notifyParentSession: mockNotify,
sessionStatuses: { "ses-1": { type: "busy" } },
})
//#then — "busy" sessions must be protected from stale-kill
expect(task.status).toBe("running")
})
it("should NOT interrupt task when session is in retry state", async () => {
//#given — lastUpdate is 5min old but session is retrying
const task = createRunningTask({
startedAt: new Date(Date.now() - 300_000),
progress: {
toolCalls: 1,
lastUpdate: new Date(Date.now() - 300_000),
},
})
//#when — session status is "retry" (OpenCode retries on transient API errors)
await checkAndInterruptStaleTasks({
tasks: [task],
client: mockClient as never,
config: { staleTimeoutMs: 180_000 },
concurrencyManager: mockConcurrencyManager as never,
notifyParentSession: mockNotify,
sessionStatuses: { "ses-1": { type: "retry" } },
})
//#then — retry sessions must be protected from stale-kill
expect(task.status).toBe("running")
})
it("should NOT interrupt busy session even with no progress (undefined lastUpdate)", async () => {
//#given — no progress at all, session is "busy" (thinking model with no streamed tokens yet)
const task = createRunningTask({
startedAt: new Date(Date.now() - 15 * 60 * 1000),
progress: undefined,
})
//#when — session is busy
await checkAndInterruptStaleTasks({
tasks: [task],
client: mockClient as never,
config: { messageStalenessTimeoutMs: 600_000 },
concurrencyManager: mockConcurrencyManager as never,
notifyParentSession: mockNotify,
sessionStatuses: { "ses-1": { type: "busy" } },
})
//#then — busy sessions with no progress must survive
expect(task.status).toBe("running")
})
it("should release concurrency key when interrupting a never-updated task", async () => {
//#given
const releaseMock = mock(() => {})
const task = createRunningTask({
startedAt: new Date(Date.now() - 15 * 60 * 1000),
progress: undefined,
concurrencyKey: "anthropic/claude-opus-4-6",
})
//#when
await checkAndInterruptStaleTasks({
tasks: [task],
client: mockClient as never,
config: { messageStalenessTimeoutMs: 600_000 },
concurrencyManager: { release: releaseMock } as never,
notifyParentSession: mockNotify,
})
//#then
expect(releaseMock).toHaveBeenCalledWith("anthropic/claude-opus-4-6")
expect(task.concurrencyKey).toBeUndefined()
})
})
describe("pruneStaleTasksAndNotifications", () => {
it("should prune tasks that exceeded TTL", () => {
//#given
const tasks = new Map<string, BackgroundTask>()
const oldTask: BackgroundTask = {
id: "old-task",
parentSessionID: "parent",
parentMessageID: "msg",
description: "old",
prompt: "old",
agent: "explore",
status: "running",
startedAt: new Date(Date.now() - 31 * 60 * 1000),
}
tasks.set("old-task", oldTask)
const pruned: string[] = []
const notifications = new Map<string, BackgroundTask[]>()
//#when
pruneStaleTasksAndNotifications({
tasks,
notifications,
onTaskPruned: (taskId) => pruned.push(taskId),
})
//#then
expect(pruned).toContain("old-task")
})
})

View File

@@ -6,6 +6,7 @@ import type { ConcurrencyManager } from "./concurrency"
import type { OpencodeClient } from "./opencode-client"
import {
DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS,
DEFAULT_STALE_TIMEOUT_MS,
MIN_RUNTIME_BEFORE_STALE_MS,
TASK_TTL_MS,
@@ -56,26 +57,60 @@ export function pruneStaleTasksAndNotifications(args: {
}
}
export type SessionStatusMap = Record<string, { type: string }>
export async function checkAndInterruptStaleTasks(args: {
tasks: Iterable<BackgroundTask>
client: OpencodeClient
config: BackgroundTaskConfig | undefined
concurrencyManager: ConcurrencyManager
notifyParentSession: (task: BackgroundTask) => Promise<void>
sessionStatuses?: SessionStatusMap
}): Promise<void> {
const { tasks, client, config, concurrencyManager, notifyParentSession } = args
const { tasks, client, config, concurrencyManager, notifyParentSession, sessionStatuses } = args
const staleTimeoutMs = config?.staleTimeoutMs ?? DEFAULT_STALE_TIMEOUT_MS
const now = Date.now()
const messageStalenessMs = config?.messageStalenessTimeoutMs ?? DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS
for (const task of tasks) {
if (task.status !== "running") continue
if (!task.progress?.lastUpdate) continue
const startedAt = task.startedAt
const sessionID = task.sessionID
if (!startedAt || !sessionID) continue
const sessionStatus = sessionStatuses?.[sessionID]?.type
const sessionIsRunning = sessionStatus !== undefined && sessionStatus !== "idle"
const runtime = now - startedAt.getTime()
if (!task.progress?.lastUpdate) {
if (sessionIsRunning) continue
if (runtime <= messageStalenessMs) continue
const staleMinutes = Math.round(runtime / 60000)
task.status = "cancelled"
task.error = `Stale timeout (no activity for ${staleMinutes}min since start)`
task.completedAt = new Date()
if (task.concurrencyKey) {
concurrencyManager.release(task.concurrencyKey)
task.concurrencyKey = undefined
}
client.session.abort({ path: { id: sessionID } }).catch(() => {})
log(`[background-agent] Task ${task.id} interrupted: no progress since start`)
try {
await notifyParentSession(task)
} catch (err) {
log("[background-agent] Error in notifyParentSession for stale task:", { taskId: task.id, error: err })
}
continue
}
if (sessionIsRunning) continue
if (runtime < MIN_RUNTIME_BEFORE_STALE_MS) continue
const timeSinceLastUpdate = now - task.progress.lastUpdate.getTime()
@@ -92,10 +127,7 @@ export async function checkAndInterruptStaleTasks(args: {
task.concurrencyKey = undefined
}
client.session.abort({
path: { id: sessionID },
}).catch(() => {})
client.session.abort({ path: { id: sessionID } }).catch(() => {})
log(`[background-agent] Task ${task.id} interrupted: stale timeout`)
try {

View File

@@ -37,6 +37,8 @@ export interface BackgroundTask {
concurrencyGroup?: string
/** Parent session's agent name for notification */
parentAgent?: string
/** Parent session's tool restrictions for notification prompts */
parentTools?: Record<string, boolean>
/** Marks if the task was launched from an unstable agent/category */
isUnstableAgent?: boolean
/** Category used for this task (e.g., 'quick', 'visual-engineering') */
@@ -56,6 +58,7 @@ export interface LaunchInput {
parentMessageID: string
parentModel?: { providerID: string; modelID: string }
parentAgent?: string
parentTools?: Record<string, boolean>
model?: { providerID: string; modelID: string; variant?: string }
isUnstableAgent?: boolean
skills?: string[]
@@ -70,4 +73,5 @@ export interface ResumeInput {
parentMessageID: string
parentModel?: { providerID: string; modelID: string }
parentAgent?: string
parentTools?: Record<string, boolean>
}

View File

@@ -0,0 +1,48 @@
import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test"
import { mkdirSync, writeFileSync, rmSync } from "fs"
import { join } from "path"
import { tmpdir } from "os"
const TEST_DIR = join(tmpdir(), "agents-global-skills-test-" + Date.now())
const TEMP_HOME = join(TEST_DIR, "home")
describe("discoverGlobalAgentsSkills", () => {
beforeEach(() => {
mkdirSync(TEST_DIR, { recursive: true })
mkdirSync(TEMP_HOME, { recursive: true })
})
afterEach(() => {
mock.restore()
rmSync(TEST_DIR, { recursive: true, force: true })
})
it("#given a skill in ~/.agents/skills/ #when discoverGlobalAgentsSkills is called #then it discovers the skill", async () => {
//#given
const skillContent = `---
name: agent-global-skill
description: A skill from global .agents/skills directory
---
Skill body.
`
const agentsGlobalSkillsDir = join(TEMP_HOME, ".agents", "skills")
const skillDir = join(agentsGlobalSkillsDir, "agent-global-skill")
mkdirSync(skillDir, { recursive: true })
writeFileSync(join(skillDir, "SKILL.md"), skillContent)
mock.module("os", () => ({
homedir: () => TEMP_HOME,
tmpdir,
}))
//#when
const { discoverGlobalAgentsSkills } = await import("./loader")
const skills = await discoverGlobalAgentsSkills()
const skill = skills.find(s => s.name === "agent-global-skill")
//#then
expect(skill).toBeDefined()
expect(skill?.scope).toBe("user")
expect(skill?.definition.description).toContain("A skill from global .agents/skills directory")
})
})

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,4 +560,60 @@ Skill body.
}
})
})
describe("agents skills discovery (.agents/skills/)", () => {
it("#given a skill in .agents/skills/ #when discoverProjectAgentsSkills is called #then it discovers the skill", async () => {
//#given
const skillContent = `---
name: agent-project-skill
description: A skill from project .agents/skills directory
---
Skill body.
`
const agentsProjectSkillsDir = join(TEST_DIR, ".agents", "skills")
const skillDir = join(agentsProjectSkillsDir, "agent-project-skill")
mkdirSync(skillDir, { recursive: true })
writeFileSync(join(skillDir, "SKILL.md"), skillContent)
//#when
const { discoverProjectAgentsSkills } = await import("./loader")
const originalCwd = process.cwd()
process.chdir(TEST_DIR)
try {
const skills = await discoverProjectAgentsSkills()
const skill = skills.find(s => s.name === "agent-project-skill")
//#then
expect(skill).toBeDefined()
expect(skill?.scope).toBe("project")
expect(skill?.definition.description).toContain("A skill from project .agents/skills directory")
} finally {
process.chdir(originalCwd)
}
})
it("#given a skill in .agents/skills/ #when discoverProjectAgentsSkills is called with directory #then it discovers the skill", async () => {
//#given
const skillContent = `---
name: agent-dir-skill
description: A skill via explicit directory param
---
Skill body.
`
const agentsProjectSkillsDir = join(TEST_DIR, ".agents", "skills")
const skillDir = join(agentsProjectSkillsDir, "agent-dir-skill")
mkdirSync(skillDir, { recursive: true })
writeFileSync(join(skillDir, "SKILL.md"), skillContent)
//#when
const { discoverProjectAgentsSkills } = await import("./loader")
const skills = await discoverProjectAgentsSkills(TEST_DIR)
const skill = skills.find(s => s.name === "agent-dir-skill")
//#then
expect(skill).toBeDefined()
expect(skill?.scope).toBe("project")
})
})
})

View File

@@ -1,4 +1,5 @@
import { join } from "path"
import { homedir } from "os"
import { getClaudeConfigDir } from "../../shared/claude-config-dir"
import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir"
import type { CommandDefinition } from "../claude-code-command-loader/types"
@@ -38,15 +39,25 @@ export interface DiscoverSkillsOptions {
}
export async function discoverAllSkills(directory?: string): Promise<LoadedSkill[]> {
const [opencodeProjectSkills, opencodeGlobalSkills, projectSkills, userSkills] = await Promise.all([
discoverOpencodeProjectSkills(directory),
discoverOpencodeGlobalSkills(),
discoverProjectClaudeSkills(directory),
discoverUserClaudeSkills(),
])
const [opencodeProjectSkills, opencodeGlobalSkills, projectSkills, userSkills, agentsProjectSkills, agentsGlobalSkills] =
await Promise.all([
discoverOpencodeProjectSkills(directory),
discoverOpencodeGlobalSkills(),
discoverProjectClaudeSkills(directory),
discoverUserClaudeSkills(),
discoverProjectAgentsSkills(directory),
discoverGlobalAgentsSkills(),
])
// Priority: opencode-project > opencode > project > user
return deduplicateSkillsByName([...opencodeProjectSkills, ...opencodeGlobalSkills, ...projectSkills, ...userSkills])
// Priority: opencode-project > opencode > project (.claude + .agents) > user (.claude + .agents)
return deduplicateSkillsByName([
...opencodeProjectSkills,
...opencodeGlobalSkills,
...projectSkills,
...agentsProjectSkills,
...userSkills,
...agentsGlobalSkills,
])
}
export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promise<LoadedSkill[]> {
@@ -62,13 +73,22 @@ export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promi
return deduplicateSkillsByName([...opencodeProjectSkills, ...opencodeGlobalSkills])
}
const [projectSkills, userSkills] = await Promise.all([
const [projectSkills, userSkills, agentsProjectSkills, agentsGlobalSkills] = await Promise.all([
discoverProjectClaudeSkills(directory),
discoverUserClaudeSkills(),
discoverProjectAgentsSkills(directory),
discoverGlobalAgentsSkills(),
])
// Priority: opencode-project > opencode > project > user
return deduplicateSkillsByName([...opencodeProjectSkills, ...opencodeGlobalSkills, ...projectSkills, ...userSkills])
// Priority: opencode-project > opencode > project (.claude + .agents) > user (.claude + .agents)
return deduplicateSkillsByName([
...opencodeProjectSkills,
...opencodeGlobalSkills,
...projectSkills,
...agentsProjectSkills,
...userSkills,
...agentsGlobalSkills,
])
}
export async function getSkillByName(name: string, options: DiscoverSkillsOptions = {}): Promise<LoadedSkill | undefined> {
@@ -96,3 +116,13 @@ export async function discoverOpencodeProjectSkills(directory?: string): Promise
const opencodeProjectDir = join(directory ?? process.cwd(), ".opencode", "skills")
return loadSkillsFromDir({ skillsDir: opencodeProjectDir, scope: "opencode-project" })
}
export async function discoverProjectAgentsSkills(directory?: string): Promise<LoadedSkill[]> {
const agentsProjectDir = join(directory ?? process.cwd(), ".agents", "skills")
return loadSkillsFromDir({ skillsDir: agentsProjectDir, scope: "project" })
}
export async function discoverGlobalAgentsSkills(): Promise<LoadedSkill[]> {
const agentsGlobalDir = join(homedir(), ".agents", "skills")
return loadSkillsFromDir({ skillsDir: agentsGlobalDir, scope: "user" })
}

View File

@@ -351,4 +351,47 @@ describe("calculateCapacity", () => {
expect(capacity.rows).toBe(4)
expect(capacity.total).toBe(12)
})
it("#given a smaller minPaneWidth #when calculating capacity #then fits more columns", () => {
//#given
const smallMinWidth = 30
//#when
const defaultCapacity = calculateCapacity(212, 44)
const customCapacity = calculateCapacity(212, 44, smallMinWidth)
//#then
expect(customCapacity.cols).toBeGreaterThanOrEqual(defaultCapacity.cols)
})
})
describe("decideSpawnActions with custom agentPaneWidth", () => {
const createWindowState = (
windowWidth: number,
windowHeight: number,
agentPanes: Array<{ paneId: string; width: number; height: number; left: number; top: number }> = []
): WindowState => ({
windowWidth,
windowHeight,
mainPane: { paneId: "%0", width: Math.floor(windowWidth / 2), height: windowHeight, left: 0, top: 0, title: "main", isActive: true },
agentPanes: agentPanes.map((p, i) => ({
...p,
title: `agent-${i}`,
isActive: false,
})),
})
it("#given a smaller agentPaneWidth #when window would be too small for default #then spawns with custom config", () => {
//#given
const smallConfig: CapacityConfig = { mainPaneMinWidth: 120, agentPaneWidth: 25 }
const state = createWindowState(100, 30)
//#when
const defaultResult = decideSpawnActions(state, "ses1", "test", { mainPaneMinWidth: 120, agentPaneWidth: 52 }, [])
const customResult = decideSpawnActions(state, "ses1", "test", smallConfig, [])
//#then
expect(defaultResult.canSpawn).toBe(false)
expect(customResult.canSpawn).toBe(true)
})
})

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,6 +27,7 @@ export interface GridPlan {
export function calculateCapacity(
windowWidth: number,
windowHeight: number,
minPaneWidth: number = MIN_PANE_WIDTH,
): GridCapacity {
const availableWidth = Math.floor(windowWidth * (1 - MAIN_PANE_RATIO))
const cols = Math.min(
@@ -34,7 +35,7 @@ export function calculateCapacity(
Math.max(
0,
Math.floor(
(availableWidth + DIVIDER_SIZE) / (MIN_PANE_WIDTH + DIVIDER_SIZE),
(availableWidth + DIVIDER_SIZE) / (minPaneWidth + DIVIDER_SIZE),
),
),
)

View File

@@ -1,3 +1,4 @@
import { MIN_PANE_HEIGHT, MIN_PANE_WIDTH } from "./types"
import type { SplitDirection, TmuxPaneInfo } from "./types"
import {
DIVIDER_SIZE,
@@ -7,6 +8,10 @@ import {
MIN_SPLIT_WIDTH,
} from "./tmux-grid-constants"
function minSplitWidthFor(minPaneWidth: number): number {
return 2 * minPaneWidth + DIVIDER_SIZE
}
export function getColumnCount(paneCount: number): number {
if (paneCount <= 0) return 1
return Math.min(MAX_COLS, Math.max(1, Math.ceil(paneCount / MAX_ROWS)))
@@ -21,26 +26,32 @@ export function getColumnWidth(agentAreaWidth: number, paneCount: number): numbe
export function isSplittableAtCount(
agentAreaWidth: number,
paneCount: number,
minPaneWidth: number = MIN_PANE_WIDTH,
): boolean {
const columnWidth = getColumnWidth(agentAreaWidth, paneCount)
return columnWidth >= MIN_SPLIT_WIDTH
return columnWidth >= minSplitWidthFor(minPaneWidth)
}
export function findMinimalEvictions(
agentAreaWidth: number,
currentCount: number,
minPaneWidth: number = MIN_PANE_WIDTH,
): number | null {
for (let k = 1; k <= currentCount; k++) {
if (isSplittableAtCount(agentAreaWidth, currentCount - k)) {
if (isSplittableAtCount(agentAreaWidth, currentCount - k, minPaneWidth)) {
return k
}
}
return null
}
export function canSplitPane(pane: TmuxPaneInfo, direction: SplitDirection): boolean {
export function canSplitPane(
pane: TmuxPaneInfo,
direction: SplitDirection,
minPaneWidth: number = MIN_PANE_WIDTH,
): boolean {
if (direction === "-h") {
return pane.width >= MIN_SPLIT_WIDTH
return pane.width >= minSplitWidthFor(minPaneWidth)
}
return pane.height >= MIN_SPLIT_HEIGHT
}

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 < MIN_PANE_WIDTH) {
if (agentAreaWidth < minPaneWidth) {
return {
canSpawn: false,
actions: [],
@@ -44,7 +44,7 @@ export function decideSpawnActions(
if (currentCount === 0) {
const virtualMainPane: TmuxPaneInfo = { ...state.mainPane, width: state.windowWidth }
if (canSplitPane(virtualMainPane, "-h")) {
if (canSplitPane(virtualMainPane, "-h", minPaneWidth)) {
return {
canSpawn: true,
actions: [
@@ -61,7 +61,7 @@ export function decideSpawnActions(
return { canSpawn: false, actions: [], reason: "mainPane too small to split" }
}
if (isSplittableAtCount(agentAreaWidth, currentCount)) {
if (isSplittableAtCount(agentAreaWidth, currentCount, minPaneWidth)) {
const spawnTarget = findSpawnTarget(state)
if (spawnTarget) {
return {
@@ -79,7 +79,7 @@ export function decideSpawnActions(
}
}
const minEvictions = findMinimalEvictions(agentAreaWidth, currentCount)
const minEvictions = findMinimalEvictions(agentAreaWidth, currentCount, minPaneWidth)
if (minEvictions === 1 && oldestPane) {
return {
canSpawn: true,

View File

@@ -0,0 +1,97 @@
/// <reference types="bun-types" />
import { describe, expect, it } from "bun:test"
import { parseAnthropicTokenLimitError } from "./parser"
describe("parseAnthropicTokenLimitError", () => {
it("#given a standard token limit error string #when parsing #then extracts tokens", () => {
//#given
const error = "prompt is too long: 250000 tokens > 200000 maximum"
//#when
const result = parseAnthropicTokenLimitError(error)
//#then
expect(result).not.toBeNull()
expect(result!.currentTokens).toBe(250000)
expect(result!.maxTokens).toBe(200000)
})
it("#given a non-token-limit error #when parsing #then returns null", () => {
//#given
const error = { message: "internal server error" }
//#when
const result = parseAnthropicTokenLimitError(error)
//#then
expect(result).toBeNull()
})
it("#given null input #when parsing #then returns null", () => {
//#given
const error = null
//#when
const result = parseAnthropicTokenLimitError(error)
//#then
expect(result).toBeNull()
})
it("#given a proxy error with non-standard structure #when parsing #then returns null without crashing", () => {
//#given
const proxyError = {
data: [1, 2, 3],
error: "string-not-object",
message: "Failed to process error response",
}
//#when
const result = parseAnthropicTokenLimitError(proxyError)
//#then
expect(result).toBeNull()
})
it("#given a circular reference error #when parsing #then returns null without crashing", () => {
//#given
const circular: Record<string, unknown> = { message: "prompt is too long" }
circular.self = circular
//#when
const result = parseAnthropicTokenLimitError(circular)
//#then
expect(result).not.toBeNull()
})
it("#given an error where data.responseBody has invalid JSON #when parsing #then handles gracefully", () => {
//#given
const error = {
data: { responseBody: "not valid json {{{" },
message: "prompt is too long with 300000 tokens exceeds 200000",
}
//#when
const result = parseAnthropicTokenLimitError(error)
//#then
expect(result).not.toBeNull()
expect(result!.currentTokens).toBe(300000)
expect(result!.maxTokens).toBe(200000)
})
it("#given an error with data as a string (not object) #when parsing #then does not crash", () => {
//#given
const error = {
data: "some-string-data",
message: "token limit exceeded",
}
//#when
const result = parseAnthropicTokenLimitError(error)
//#then
expect(result).not.toBeNull()
})
})

View File

@@ -74,6 +74,14 @@ function isTokenLimitError(text: string): boolean {
}
export function parseAnthropicTokenLimitError(err: unknown): ParsedTokenLimitError | null {
try {
return parseAnthropicTokenLimitErrorUnsafe(err)
} catch {
return null
}
}
function parseAnthropicTokenLimitErrorUnsafe(err: unknown): ParsedTokenLimitError | null {
if (typeof err === "string") {
if (err.toLowerCase().includes("non-empty content")) {
return {

View File

@@ -10,18 +10,9 @@ import {
} from "../../features/boulder-state"
import type { BoulderState } from "../../features/boulder-state"
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-message-storage-${randomUUID()}`)
const TEST_MESSAGE_STORAGE = join(TEST_STORAGE_ROOT, "message")
const TEST_PART_STORAGE = join(TEST_STORAGE_ROOT, "part")
mock.module("../../features/hook-message-injector/constants", () => ({
OPENCODE_STORAGE: TEST_STORAGE_ROOT,
MESSAGE_STORAGE: TEST_MESSAGE_STORAGE,
PART_STORAGE: TEST_PART_STORAGE,
}))
const { createAtlasHook } = await import("./index")
const { MESSAGE_STORAGE } = await import("../../features/hook-message-injector")
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
import { _resetForTesting, subagentSessions } from "../../features/claude-code-session-state"
import { createAtlasHook } from "./index"
describe("atlas hook", () => {
let TEST_DIR: string
@@ -77,7 +68,6 @@ describe("atlas hook", () => {
if (existsSync(TEST_DIR)) {
rmSync(TEST_DIR, { recursive: true, force: true })
}
rmSync(TEST_STORAGE_ROOT, { recursive: true, force: true })
})
describe("tool.execute.after handler", () => {
@@ -631,15 +621,14 @@ describe("atlas hook", () => {
}
beforeEach(() => {
mock.module("../../features/claude-code-session-state", () => ({
getMainSessionID: () => MAIN_SESSION_ID,
subagentSessions: new Set<string>(),
}))
_resetForTesting()
subagentSessions.clear()
setupMessageStorage(MAIN_SESSION_ID, "atlas")
})
afterEach(() => {
cleanupMessageStorage(MAIN_SESSION_ID)
_resetForTesting()
})
test("should inject continuation when boulder has incomplete tasks", async () => {

View File

@@ -1,53 +1,67 @@
import { beforeEach, describe, expect, it, mock } from "bun:test"
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
const readFileSyncMock = mock((_: string, __: string) => "# AGENTS")
const findAgentsMdUpMock = mock((_: { startDir: string; rootDir: string }) => [] as string[])
const resolveFilePathMock = mock((_: string, path: string) => path)
const loadInjectedPathsMock = mock((_: string) => new Set<string>())
const saveInjectedPathsMock = mock((_: string, __: Set<string>) => {})
mock.module("node:fs", () => ({
readFileSync: readFileSyncMock,
}))
mock.module("./finder", () => ({
findAgentsMdUp: findAgentsMdUpMock,
resolveFilePath: resolveFilePathMock,
}))
mock.module("./storage", () => ({
loadInjectedPaths: loadInjectedPathsMock,
saveInjectedPaths: saveInjectedPathsMock,
}))
const { processFilePathForAgentsInjection } = await import("./injector")
describe("processFilePathForAgentsInjection", () => {
let testRoot = ""
beforeEach(() => {
readFileSyncMock.mockClear()
findAgentsMdUpMock.mockClear()
resolveFilePathMock.mockClear()
loadInjectedPathsMock.mockClear()
saveInjectedPathsMock.mockClear()
testRoot = join(
tmpdir(),
`directory-agents-injector-${Date.now()}-${Math.random().toString(16).slice(2)}`
)
mkdirSync(testRoot, { recursive: true })
})
afterEach(() => {
mock.restore()
rmSync(testRoot, { recursive: true, force: true })
})
it("does not save when all discovered paths are already cached", async () => {
//#given
const sessionID = "session-1"
const cachedDirectory = "/repo/src"
const repoRoot = join(testRoot, "repo")
const agentsPath = join(repoRoot, "src", "AGENTS.md")
const cachedDirectory = join(repoRoot, "src")
mkdirSync(join(repoRoot, "src"), { recursive: true })
writeFileSync(agentsPath, "# AGENTS")
loadInjectedPathsMock.mockReturnValueOnce(new Set([cachedDirectory]))
findAgentsMdUpMock.mockReturnValueOnce(["/repo/src/AGENTS.md"])
findAgentsMdUpMock.mockReturnValueOnce([agentsPath])
const truncator = {
truncate: mock(async () => ({ result: "trimmed", truncated: false })),
}
mock.module("./finder", () => ({
findAgentsMdUp: findAgentsMdUpMock,
resolveFilePath: resolveFilePathMock,
}))
mock.module("./storage", () => ({
loadInjectedPaths: loadInjectedPathsMock,
saveInjectedPaths: saveInjectedPathsMock,
}))
const { processFilePathForAgentsInjection } = await import("./injector")
//#when
await processFilePathForAgentsInjection({
ctx: { directory: "/repo" } as never,
ctx: { directory: repoRoot } as never,
truncator: truncator as never,
sessionCaches: new Map(),
filePath: "/repo/src/file.ts",
filePath: join(repoRoot, "src", "file.ts"),
sessionID,
output: { title: "Result", output: "", metadata: {} },
})
@@ -59,19 +73,36 @@ describe("processFilePathForAgentsInjection", () => {
it("saves when a new path is injected", async () => {
//#given
const sessionID = "session-2"
const repoRoot = join(testRoot, "repo")
const agentsPath = join(repoRoot, "src", "AGENTS.md")
const injectedDirectory = join(repoRoot, "src")
mkdirSync(join(repoRoot, "src"), { recursive: true })
writeFileSync(agentsPath, "# AGENTS")
loadInjectedPathsMock.mockReturnValueOnce(new Set())
findAgentsMdUpMock.mockReturnValueOnce(["/repo/src/AGENTS.md"])
findAgentsMdUpMock.mockReturnValueOnce([agentsPath])
const truncator = {
truncate: mock(async () => ({ result: "trimmed", truncated: false })),
}
mock.module("./finder", () => ({
findAgentsMdUp: findAgentsMdUpMock,
resolveFilePath: resolveFilePathMock,
}))
mock.module("./storage", () => ({
loadInjectedPaths: loadInjectedPathsMock,
saveInjectedPaths: saveInjectedPathsMock,
}))
const { processFilePathForAgentsInjection } = await import("./injector")
//#when
await processFilePathForAgentsInjection({
ctx: { directory: "/repo" } as never,
ctx: { directory: repoRoot } as never,
truncator: truncator as never,
sessionCaches: new Map(),
filePath: "/repo/src/file.ts",
filePath: join(repoRoot, "src", "file.ts"),
sessionID,
output: { title: "Result", output: "", metadata: {} },
})
@@ -80,28 +111,44 @@ describe("processFilePathForAgentsInjection", () => {
expect(saveInjectedPathsMock).toHaveBeenCalledTimes(1)
const saveCall = saveInjectedPathsMock.mock.calls[0]
expect(saveCall[0]).toBe(sessionID)
expect((saveCall[1] as Set<string>).has("/repo/src")).toBe(true)
expect((saveCall[1] as Set<string>).has(injectedDirectory)).toBe(true)
})
it("saves once when cached and new paths are mixed", async () => {
//#given
const sessionID = "session-3"
loadInjectedPathsMock.mockReturnValueOnce(new Set(["/repo/already-cached"]))
findAgentsMdUpMock.mockReturnValueOnce([
"/repo/already-cached/AGENTS.md",
"/repo/new-dir/AGENTS.md",
])
const repoRoot = join(testRoot, "repo")
const cachedAgentsPath = join(repoRoot, "already-cached", "AGENTS.md")
const newAgentsPath = join(repoRoot, "new-dir", "AGENTS.md")
mkdirSync(join(repoRoot, "already-cached"), { recursive: true })
mkdirSync(join(repoRoot, "new-dir"), { recursive: true })
writeFileSync(cachedAgentsPath, "# AGENTS")
writeFileSync(newAgentsPath, "# AGENTS")
loadInjectedPathsMock.mockReturnValueOnce(new Set([join(repoRoot, "already-cached")]))
findAgentsMdUpMock.mockReturnValueOnce([cachedAgentsPath, newAgentsPath])
const truncator = {
truncate: mock(async () => ({ result: "trimmed", truncated: false })),
}
mock.module("./finder", () => ({
findAgentsMdUp: findAgentsMdUpMock,
resolveFilePath: resolveFilePathMock,
}))
mock.module("./storage", () => ({
loadInjectedPaths: loadInjectedPathsMock,
saveInjectedPaths: saveInjectedPathsMock,
}))
const { processFilePathForAgentsInjection } = await import("./injector")
//#when
await processFilePathForAgentsInjection({
ctx: { directory: "/repo" } as never,
ctx: { directory: repoRoot } as never,
truncator: truncator as never,
sessionCaches: new Map(),
filePath: "/repo/new-dir/file.ts",
filePath: join(repoRoot, "new-dir", "file.ts"),
sessionID,
output: { title: "Result", output: "", metadata: {} },
})
@@ -109,6 +156,6 @@ describe("processFilePathForAgentsInjection", () => {
//#then
expect(saveInjectedPathsMock).toHaveBeenCalledTimes(1)
const saveCall = saveInjectedPathsMock.mock.calls[0]
expect((saveCall[1] as Set<string>).has("/repo/new-dir")).toBe(true)
expect((saveCall[1] as Set<string>).has(join(repoRoot, "new-dir"))).toBe(true)
})
})

View File

@@ -1,53 +1,67 @@
import { beforeEach, describe, expect, it, mock } from "bun:test"
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
const readFileSyncMock = mock((_: string, __: string) => "# README")
const findReadmeMdUpMock = mock((_: { startDir: string; rootDir: string }) => [] as string[])
const resolveFilePathMock = mock((_: string, path: string) => path)
const loadInjectedPathsMock = mock((_: string) => new Set<string>())
const saveInjectedPathsMock = mock((_: string, __: Set<string>) => {})
mock.module("node:fs", () => ({
readFileSync: readFileSyncMock,
}))
mock.module("./finder", () => ({
findReadmeMdUp: findReadmeMdUpMock,
resolveFilePath: resolveFilePathMock,
}))
mock.module("./storage", () => ({
loadInjectedPaths: loadInjectedPathsMock,
saveInjectedPaths: saveInjectedPathsMock,
}))
const { processFilePathForReadmeInjection } = await import("./injector")
describe("processFilePathForReadmeInjection", () => {
let testRoot = ""
beforeEach(() => {
readFileSyncMock.mockClear()
findReadmeMdUpMock.mockClear()
resolveFilePathMock.mockClear()
loadInjectedPathsMock.mockClear()
saveInjectedPathsMock.mockClear()
testRoot = join(
tmpdir(),
`directory-readme-injector-${Date.now()}-${Math.random().toString(16).slice(2)}`
)
mkdirSync(testRoot, { recursive: true })
})
afterEach(() => {
mock.restore()
rmSync(testRoot, { recursive: true, force: true })
})
it("does not save when all discovered paths are already cached", async () => {
//#given
const sessionID = "session-1"
const cachedDirectory = "/repo/src"
const repoRoot = join(testRoot, "repo")
const readmePath = join(repoRoot, "src", "README.md")
const cachedDirectory = join(repoRoot, "src")
mkdirSync(join(repoRoot, "src"), { recursive: true })
writeFileSync(readmePath, "# README")
loadInjectedPathsMock.mockReturnValueOnce(new Set([cachedDirectory]))
findReadmeMdUpMock.mockReturnValueOnce(["/repo/src/README.md"])
findReadmeMdUpMock.mockReturnValueOnce([readmePath])
const truncator = {
truncate: mock(async () => ({ result: "trimmed", truncated: false })),
}
mock.module("./finder", () => ({
findReadmeMdUp: findReadmeMdUpMock,
resolveFilePath: resolveFilePathMock,
}))
mock.module("./storage", () => ({
loadInjectedPaths: loadInjectedPathsMock,
saveInjectedPaths: saveInjectedPathsMock,
}))
const { processFilePathForReadmeInjection } = await import("./injector")
//#when
await processFilePathForReadmeInjection({
ctx: { directory: "/repo" } as never,
ctx: { directory: repoRoot } as never,
truncator: truncator as never,
sessionCaches: new Map(),
filePath: "/repo/src/file.ts",
filePath: join(repoRoot, "src", "file.ts"),
sessionID,
output: { title: "Result", output: "", metadata: {} },
})
@@ -59,19 +73,36 @@ describe("processFilePathForReadmeInjection", () => {
it("saves when a new path is injected", async () => {
//#given
const sessionID = "session-2"
const repoRoot = join(testRoot, "repo")
const readmePath = join(repoRoot, "src", "README.md")
const injectedDirectory = join(repoRoot, "src")
mkdirSync(join(repoRoot, "src"), { recursive: true })
writeFileSync(readmePath, "# README")
loadInjectedPathsMock.mockReturnValueOnce(new Set())
findReadmeMdUpMock.mockReturnValueOnce(["/repo/src/README.md"])
findReadmeMdUpMock.mockReturnValueOnce([readmePath])
const truncator = {
truncate: mock(async () => ({ result: "trimmed", truncated: false })),
}
mock.module("./finder", () => ({
findReadmeMdUp: findReadmeMdUpMock,
resolveFilePath: resolveFilePathMock,
}))
mock.module("./storage", () => ({
loadInjectedPaths: loadInjectedPathsMock,
saveInjectedPaths: saveInjectedPathsMock,
}))
const { processFilePathForReadmeInjection } = await import("./injector")
//#when
await processFilePathForReadmeInjection({
ctx: { directory: "/repo" } as never,
ctx: { directory: repoRoot } as never,
truncator: truncator as never,
sessionCaches: new Map(),
filePath: "/repo/src/file.ts",
filePath: join(repoRoot, "src", "file.ts"),
sessionID,
output: { title: "Result", output: "", metadata: {} },
})
@@ -80,28 +111,44 @@ describe("processFilePathForReadmeInjection", () => {
expect(saveInjectedPathsMock).toHaveBeenCalledTimes(1)
const saveCall = saveInjectedPathsMock.mock.calls[0]
expect(saveCall[0]).toBe(sessionID)
expect((saveCall[1] as Set<string>).has("/repo/src")).toBe(true)
expect((saveCall[1] as Set<string>).has(injectedDirectory)).toBe(true)
})
it("saves once when cached and new paths are mixed", async () => {
//#given
const sessionID = "session-3"
loadInjectedPathsMock.mockReturnValueOnce(new Set(["/repo/already-cached"]))
findReadmeMdUpMock.mockReturnValueOnce([
"/repo/already-cached/README.md",
"/repo/new-dir/README.md",
])
const repoRoot = join(testRoot, "repo")
const cachedReadmePath = join(repoRoot, "already-cached", "README.md")
const newReadmePath = join(repoRoot, "new-dir", "README.md")
mkdirSync(join(repoRoot, "already-cached"), { recursive: true })
mkdirSync(join(repoRoot, "new-dir"), { recursive: true })
writeFileSync(cachedReadmePath, "# README")
writeFileSync(newReadmePath, "# README")
loadInjectedPathsMock.mockReturnValueOnce(new Set([join(repoRoot, "already-cached")]))
findReadmeMdUpMock.mockReturnValueOnce([cachedReadmePath, newReadmePath])
const truncator = {
truncate: mock(async () => ({ result: "trimmed", truncated: false })),
}
mock.module("./finder", () => ({
findReadmeMdUp: findReadmeMdUpMock,
resolveFilePath: resolveFilePathMock,
}))
mock.module("./storage", () => ({
loadInjectedPaths: loadInjectedPathsMock,
saveInjectedPaths: saveInjectedPathsMock,
}))
const { processFilePathForReadmeInjection } = await import("./injector")
//#when
await processFilePathForReadmeInjection({
ctx: { directory: "/repo" } as never,
ctx: { directory: repoRoot } as never,
truncator: truncator as never,
sessionCaches: new Map(),
filePath: "/repo/new-dir/file.ts",
filePath: join(repoRoot, "new-dir", "file.ts"),
sessionID,
output: { title: "Result", output: "", metadata: {} },
})
@@ -109,6 +156,6 @@ describe("processFilePathForReadmeInjection", () => {
//#then
expect(saveInjectedPathsMock).toHaveBeenCalledTimes(1)
const saveCall = saveInjectedPathsMock.mock.calls[0]
expect((saveCall[1] as Set<string>).has("/repo/new-dir")).toBe(true)
expect((saveCall[1] as Set<string>).has(join(repoRoot, "new-dir"))).toBe(true)
})
})

View File

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

View File

@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
import { afterAll, afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
import * as fs from "node:fs";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import * as os from "node:os";
@@ -102,6 +102,10 @@ function getInjectedRulesPath(sessionID: string): string {
}
describe("createRuleInjectionProcessor", () => {
afterAll(() => {
mock.restore();
});
let testRoot: string;
let projectRoot: string;
let homeRoot: string;

View File

@@ -0,0 +1,129 @@
/// <reference types="bun-types" />
import { describe, expect, it } from "bun:test"
import { detectErrorType, extractMessageIndex } from "./detect-error-type"
describe("detectErrorType", () => {
it("#given a tool_use/tool_result error #when detecting #then returns tool_result_missing", () => {
//#given
const error = { message: "tool_use block must be followed by tool_result" }
//#when
const result = detectErrorType(error)
//#then
expect(result).toBe("tool_result_missing")
})
it("#given a thinking block order error #when detecting #then returns thinking_block_order", () => {
//#given
const error = { message: "thinking must be the first block in the response" }
//#when
const result = detectErrorType(error)
//#then
expect(result).toBe("thinking_block_order")
})
it("#given a thinking disabled violation #when detecting #then returns thinking_disabled_violation", () => {
//#given
const error = { message: "thinking is disabled and cannot contain thinking blocks" }
//#when
const result = detectErrorType(error)
//#then
expect(result).toBe("thinking_disabled_violation")
})
it("#given an unrecognized error #when detecting #then returns null", () => {
//#given
const error = { message: "some random error" }
//#when
const result = detectErrorType(error)
//#then
expect(result).toBeNull()
})
it("#given a malformed error with circular references #when detecting #then returns null without crashing", () => {
//#given
const circular: Record<string, unknown> = {}
circular.self = circular
//#when
const result = detectErrorType(circular)
//#then
expect(result).toBeNull()
})
it("#given a proxy error with non-standard structure #when detecting #then returns null without crashing", () => {
//#given
const proxyError = {
data: "not-an-object",
error: 42,
nested: { deeply: { error: true } },
}
//#when
const result = detectErrorType(proxyError)
//#then
expect(result).toBeNull()
})
it("#given a null error #when detecting #then returns null", () => {
//#given
const error = null
//#when
const result = detectErrorType(error)
//#then
expect(result).toBeNull()
})
it("#given an error with data.error containing message #when detecting #then extracts correctly", () => {
//#given
const error = {
data: {
error: {
message: "tool_use block requires tool_result",
},
},
}
//#when
const result = detectErrorType(error)
//#then
expect(result).toBe("tool_result_missing")
})
})
describe("extractMessageIndex", () => {
it("#given an error referencing messages.5 #when extracting #then returns 5", () => {
//#given
const error = { message: "Invalid value at messages.5: tool_result is required" }
//#when
const result = extractMessageIndex(error)
//#then
expect(result).toBe(5)
})
it("#given a malformed error #when extracting #then returns null without crashing", () => {
//#given
const circular: Record<string, unknown> = {}
circular.self = circular
//#when
const result = extractMessageIndex(circular)
//#then
expect(result).toBeNull()
})
})

View File

@@ -34,40 +34,48 @@ function getErrorMessage(error: unknown): string {
}
export function extractMessageIndex(error: unknown): number | null {
const message = getErrorMessage(error)
const match = message.match(/messages\.(\d+)/)
return match ? parseInt(match[1], 10) : null
try {
const message = getErrorMessage(error)
const match = message.match(/messages\.(\d+)/)
return match ? parseInt(match[1], 10) : null
} catch {
return null
}
}
export function detectErrorType(error: unknown): RecoveryErrorType {
const message = getErrorMessage(error)
try {
const message = getErrorMessage(error)
if (
message.includes("assistant message prefill") ||
message.includes("conversation must end with a user message")
) {
return "assistant_prefill_unsupported"
if (
message.includes("assistant message prefill") ||
message.includes("conversation must end with a user message")
) {
return "assistant_prefill_unsupported"
}
if (
message.includes("thinking") &&
(message.includes("first block") ||
message.includes("must start with") ||
message.includes("preceeding") ||
message.includes("final block") ||
message.includes("cannot be thinking") ||
(message.includes("expected") && message.includes("found")))
) {
return "thinking_block_order"
}
if (message.includes("thinking is disabled") && message.includes("cannot contain")) {
return "thinking_disabled_violation"
}
if (message.includes("tool_use") && message.includes("tool_result")) {
return "tool_result_missing"
}
return null
} catch {
return null
}
if (
message.includes("thinking") &&
(message.includes("first block") ||
message.includes("must start with") ||
message.includes("preceeding") ||
message.includes("final block") ||
message.includes("cannot be thinking") ||
(message.includes("expected") && message.includes("found")))
) {
return "thinking_block_order"
}
if (message.includes("thinking is disabled") && message.includes("cannot contain")) {
return "thinking_disabled_violation"
}
if (message.includes("tool_use") && message.includes("tool_result")) {
return "tool_result_missing"
}
return null
}

View File

@@ -10,6 +10,45 @@ export function clearThinkModeState(sessionID: string): void {
}
export function createThinkModeHook() {
function isDisabledThinkingConfig(config: Record<string, unknown>): boolean {
const thinkingConfig = config.thinking
if (
typeof thinkingConfig === "object" &&
thinkingConfig !== null &&
"type" in thinkingConfig &&
(thinkingConfig as { type?: string }).type === "disabled"
) {
return true
}
const providerOptions = config.providerOptions
if (typeof providerOptions !== "object" || providerOptions === null) {
return false
}
return Object.values(providerOptions as Record<string, unknown>).some(
(providerConfig) => {
if (typeof providerConfig !== "object" || providerConfig === null) {
return false
}
const providerConfigMap = providerConfig as Record<string, unknown>
const extraBody = providerConfigMap.extra_body
if (typeof extraBody !== "object" || extraBody === null) {
return false
}
const extraBodyMap = extraBody as Record<string, unknown>
const extraThinking = extraBodyMap.thinking
return (
typeof extraThinking === "object" &&
extraThinking !== null &&
(extraThinking as { type?: string }).type === "disabled"
)
}
)
}
return {
"chat.params": async (output: ThinkModeInput, sessionID: string): Promise<void> => {
const promptText = extractPromptText(output.parts)
@@ -75,7 +114,9 @@ export function createThinkModeHook() {
sessionID,
provider: currentModel.providerID,
})
} else {
} else if (
!isDisabledThinkingConfig(thinkingConfig as Record<string, unknown>)
) {
Object.assign(output.message, thinkingConfig)
state.thinkingConfigInjected = true
log("Think mode: thinking config injected", {
@@ -83,6 +124,11 @@ export function createThinkModeHook() {
provider: currentModel.providerID,
config: thinkingConfig,
})
} else {
log("Think mode: skipping disabled thinking config", {
sessionID,
provider: currentModel.providerID,
})
}
}

View File

@@ -352,6 +352,25 @@ describe("createThinkModeHook integration", () => {
})
describe("Agent-level thinking configuration respect", () => {
it("should omit Z.ai GLM disabled thinking config", async () => {
//#given a Z.ai GLM model with think prompt
const hook = createThinkModeHook()
const input = createMockInput(
"zai-coding-plan",
"glm-4.7",
"ultrathink mode"
)
//#when think mode resolves Z.ai thinking configuration
await hook["chat.params"](input, sessionID)
//#then thinking config should be omitted from request
const message = input.message as MessageWithInjectedProps
expect(input.message.model?.modelID).toBe("glm-4.7")
expect(message.thinking).toBeUndefined()
expect(message.providerOptions).toBeUndefined()
})
it("should NOT inject thinking config when agent has thinking disabled", async () => {
// given agent with thinking explicitly disabled
const hook = createThinkModeHook()

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import type { BackgroundManager } from "../../features/background-agent"
@@ -9,10 +10,13 @@ type TimerCallback = (...args: any[]) => void
interface FakeTimers {
advanceBy: (ms: number, advanceClock?: boolean) => Promise<void>
advanceClockBy: (ms: number) => Promise<void>
restore: () => void
}
function createFakeTimers(): FakeTimers {
const FAKE_MIN_DELAY_MS = 500
const REAL_MAX_DELAY_MS = 5000
const originalNow = Date.now()
let clockNow = originalNow
let timerNow = 0
@@ -52,20 +56,41 @@ function createFakeTimers(): FakeTimers {
}
globalThis.setTimeout = ((callback: TimerCallback, delay?: number, ...args: any[]) => {
return schedule(callback, delay, null, args) as unknown as ReturnType<typeof setTimeout>
const normalized = normalizeDelay(delay)
if (normalized < FAKE_MIN_DELAY_MS) {
return original.setTimeout(callback, delay, ...args)
}
if (normalized >= REAL_MAX_DELAY_MS) {
return original.setTimeout(callback, delay, ...args)
}
return schedule(callback, normalized, null, args) as unknown as ReturnType<typeof setTimeout>
}) as typeof setTimeout
globalThis.setInterval = ((callback: TimerCallback, delay?: number, ...args: any[]) => {
const interval = normalizeDelay(delay)
return schedule(callback, delay, interval, args) as unknown as ReturnType<typeof setInterval>
if (interval < FAKE_MIN_DELAY_MS) {
return original.setInterval(callback, delay, ...args)
}
if (interval >= REAL_MAX_DELAY_MS) {
return original.setInterval(callback, delay, ...args)
}
return schedule(callback, interval, interval, args) as unknown as ReturnType<typeof setInterval>
}) as typeof setInterval
globalThis.clearTimeout = ((id?: number) => {
clear(id)
globalThis.clearTimeout = ((id?: Parameters<typeof clearTimeout>[0]) => {
if (typeof id === "number" && timers.has(id)) {
clear(id)
return
}
original.clearTimeout(id)
}) as typeof clearTimeout
globalThis.clearInterval = ((id?: number) => {
clear(id)
globalThis.clearInterval = ((id?: Parameters<typeof clearInterval>[0]) => {
if (typeof id === "number" && timers.has(id)) {
clear(id)
return
}
original.clearInterval(id)
}) as typeof clearInterval
Date.now = () => clockNow
@@ -107,6 +132,12 @@ function createFakeTimers(): FakeTimers {
await Promise.resolve()
}
const advanceClockBy = async (ms: number) => {
const clamped = Math.max(0, ms)
clockNow += clamped
await Promise.resolve()
}
const restore = () => {
globalThis.setTimeout = original.setTimeout
globalThis.clearTimeout = original.clearTimeout
@@ -115,7 +146,7 @@ function createFakeTimers(): FakeTimers {
Date.now = original.dateNow
}
return { advanceBy, restore }
return { advanceBy, advanceClockBy, restore }
}
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
@@ -510,7 +541,7 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(2500, true)
await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true)
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
@@ -518,7 +549,7 @@ describe("todo-continuation-enforcer", () => {
//#then
expect(promptCalls).toHaveLength(2)
})
}, { timeout: 15000 })
test("should keep injecting even when todos remain unchanged across cycles", async () => {
//#given
@@ -534,26 +565,26 @@ describe("todo-continuation-enforcer", () => {
//#when — 5 consecutive idle cycles with unchanged todos
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true)
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true)
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true)
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true)
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
//#then — all 5 injections should fire (no stagnation cap)
expect(promptCalls).toHaveLength(5)
})
}, { timeout: 60000 })
test("should skip idle handling while injection is in flight", async () => {
//#given
@@ -613,7 +644,7 @@ describe("todo-continuation-enforcer", () => {
//#then
expect(promptCalls).toHaveLength(2)
})
}, { timeout: 15000 })
test("should accept skipAgents option without error", async () => {
// given - session with skipAgents configured for Prometheus

View File

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

View File

@@ -1,72 +0,0 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import { createToolRegistry } from "./tool-registry"
import type { OhMyOpenCodeConfig } from "../config/schema"
describe("team system tool registration", () => {
test("registers team tools when experimental.team_system is true", () => {
const pluginConfig = {
experimental: { team_system: true },
} as unknown as OhMyOpenCodeConfig
const result = createToolRegistry({
ctx: {} as any,
pluginConfig,
managers: {} as any,
skillContext: {} as any,
availableCategories: [],
})
expect(Object.keys(result.filteredTools)).toContain("team_create")
expect(Object.keys(result.filteredTools)).toContain("team_delete")
expect(Object.keys(result.filteredTools)).toContain("send_message")
expect(Object.keys(result.filteredTools)).toContain("read_inbox")
expect(Object.keys(result.filteredTools)).toContain("read_config")
expect(Object.keys(result.filteredTools)).toContain("force_kill_teammate")
expect(Object.keys(result.filteredTools)).toContain("process_shutdown_approved")
})
test("does not register team tools when experimental.team_system is false", () => {
const pluginConfig = {
experimental: { team_system: false },
} as unknown as OhMyOpenCodeConfig
const result = createToolRegistry({
ctx: {} as any,
pluginConfig,
managers: {} as any,
skillContext: {} as any,
availableCategories: [],
})
expect(Object.keys(result.filteredTools)).not.toContain("team_create")
expect(Object.keys(result.filteredTools)).not.toContain("team_delete")
expect(Object.keys(result.filteredTools)).not.toContain("send_message")
expect(Object.keys(result.filteredTools)).not.toContain("read_inbox")
expect(Object.keys(result.filteredTools)).not.toContain("read_config")
expect(Object.keys(result.filteredTools)).not.toContain("force_kill_teammate")
expect(Object.keys(result.filteredTools)).not.toContain("process_shutdown_approved")
})
test("does not register team tools when experimental.team_system is undefined", () => {
const pluginConfig = {
experimental: {},
} as unknown as OhMyOpenCodeConfig
const result = createToolRegistry({
ctx: {} as any,
pluginConfig,
managers: {} as any,
skillContext: {} as any,
availableCategories: [],
})
expect(Object.keys(result.filteredTools)).not.toContain("team_create")
expect(Object.keys(result.filteredTools)).not.toContain("team_delete")
expect(Object.keys(result.filteredTools)).not.toContain("send_message")
expect(Object.keys(result.filteredTools)).not.toContain("read_inbox")
expect(Object.keys(result.filteredTools)).not.toContain("read_config")
expect(Object.keys(result.filteredTools)).not.toContain("force_kill_teammate")
expect(Object.keys(result.filteredTools)).not.toContain("process_shutdown_approved")
})
})

View File

@@ -19,7 +19,6 @@ import {
createAstGrepTools,
createSessionManagerTools,
createDelegateTask,
createAgentTeamsTools,
discoverCommandsSync,
interactive_bash,
createTaskCreateTool,
@@ -118,15 +117,6 @@ export function createToolRegistry(args: {
}
: {}
const teamSystemEnabled = pluginConfig.experimental?.team_system ?? false
const agentTeamsRecord: Record<string, ToolDefinition> = teamSystemEnabled
? createAgentTeamsTools(managers.backgroundManager, {
client: ctx.client,
userCategories: pluginConfig.categories,
sisyphusJuniorModel: pluginConfig.agents?.["sisyphus-junior"]?.model,
})
: {}
const allTools: Record<string, ToolDefinition> = {
...builtinTools,
...createGrepTools(ctx),
@@ -142,7 +132,6 @@ export function createToolRegistry(args: {
slashcommand: slashcommandTool,
interactive_bash,
...taskToolsRecord,
...agentTeamsRecord,
}
const filteredTools = filterDisabledTools(allTools, pluginConfig.disabled_tools)

View File

@@ -0,0 +1,103 @@
import { describe, it, expect, beforeAll, afterAll } from "bun:test"
import { mkdirSync, writeFileSync, symlinkSync, rmSync } from "fs"
import { join } from "path"
import { tmpdir } from "os"
import { resolveSymlink, resolveSymlinkAsync, isSymbolicLink } from "./file-utils"
const testDir = join(tmpdir(), "file-utils-test-" + Date.now())
// Create a directory structure that mimics the real-world scenario:
//
// testDir/
// ├── repo/
// │ ├── skills/
// │ │ └── category/
// │ │ └── my-skill/
// │ │ └── SKILL.md
// │ └── .opencode/
// │ └── skills/
// │ └── my-skill -> ../../skills/category/my-skill (relative symlink)
// └── config/
// └── skills -> ../repo/.opencode/skills (absolute symlink)
const realSkillDir = join(testDir, "repo", "skills", "category", "my-skill")
const repoOpencodeSkills = join(testDir, "repo", ".opencode", "skills")
const configSkills = join(testDir, "config", "skills")
beforeAll(() => {
// Create real skill directory with a file
mkdirSync(realSkillDir, { recursive: true })
writeFileSync(join(realSkillDir, "SKILL.md"), "# My Skill")
// Create .opencode/skills/ with a relative symlink to the real skill
mkdirSync(repoOpencodeSkills, { recursive: true })
symlinkSync("../../skills/category/my-skill", join(repoOpencodeSkills, "my-skill"))
// Create config/skills as an absolute symlink to .opencode/skills
mkdirSync(join(testDir, "config"), { recursive: true })
symlinkSync(repoOpencodeSkills, configSkills)
})
afterAll(() => {
rmSync(testDir, { recursive: true, force: true })
})
describe("resolveSymlink", () => {
it("resolves a regular file path to itself", () => {
const filePath = join(realSkillDir, "SKILL.md")
expect(resolveSymlink(filePath)).toBe(filePath)
})
it("resolves a relative symlink to its real path", () => {
const symlinkPath = join(repoOpencodeSkills, "my-skill")
expect(resolveSymlink(symlinkPath)).toBe(realSkillDir)
})
it("resolves a chained symlink (symlink-to-dir-containing-symlinks) to the real path", () => {
// This is the real-world scenario:
// config/skills/my-skill -> (follows config/skills) -> repo/.opencode/skills/my-skill -> repo/skills/category/my-skill
const chainedPath = join(configSkills, "my-skill")
expect(resolveSymlink(chainedPath)).toBe(realSkillDir)
})
it("returns the original path for non-existent paths", () => {
const fakePath = join(testDir, "does-not-exist")
expect(resolveSymlink(fakePath)).toBe(fakePath)
})
})
describe("resolveSymlinkAsync", () => {
it("resolves a regular file path to itself", async () => {
const filePath = join(realSkillDir, "SKILL.md")
expect(await resolveSymlinkAsync(filePath)).toBe(filePath)
})
it("resolves a relative symlink to its real path", async () => {
const symlinkPath = join(repoOpencodeSkills, "my-skill")
expect(await resolveSymlinkAsync(symlinkPath)).toBe(realSkillDir)
})
it("resolves a chained symlink (symlink-to-dir-containing-symlinks) to the real path", async () => {
const chainedPath = join(configSkills, "my-skill")
expect(await resolveSymlinkAsync(chainedPath)).toBe(realSkillDir)
})
it("returns the original path for non-existent paths", async () => {
const fakePath = join(testDir, "does-not-exist")
expect(await resolveSymlinkAsync(fakePath)).toBe(fakePath)
})
})
describe("isSymbolicLink", () => {
it("returns true for a symlink", () => {
expect(isSymbolicLink(join(repoOpencodeSkills, "my-skill"))).toBe(true)
})
it("returns false for a regular directory", () => {
expect(isSymbolicLink(realSkillDir)).toBe(false)
})
it("returns false for a non-existent path", () => {
expect(isSymbolicLink(join(testDir, "does-not-exist"))).toBe(false)
})
})

View File

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

View File

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

View File

@@ -100,9 +100,10 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
"visual-engineering": {
fallbackChain: [
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
{ providers: ["zai-coding-plan"], model: "glm-5" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["zai-coding-plan"], model: "glm-4.7" },
{ providers: ["kimi-for-coding"], model: "k2p5" },
],
},
ultrabrain: {
@@ -151,10 +152,9 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
},
writing: {
fallbackChain: [
{ providers: ["kimi-for-coding"], model: "k2p5" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" },
{ providers: ["zai-coding-plan"], model: "glm-4.7" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
],
},
}

View File

@@ -0,0 +1,72 @@
import { describe, test, expect, beforeEach } from "bun:test"
import { setSessionTools, getSessionTools, clearSessionTools } from "./session-tools-store"
describe("session-tools-store", () => {
beforeEach(() => {
clearSessionTools()
})
test("returns undefined for unknown session", () => {
//#given
const sessionID = "ses_unknown"
//#when
const result = getSessionTools(sessionID)
//#then
expect(result).toBeUndefined()
})
test("stores and retrieves tools for a session", () => {
//#given
const sessionID = "ses_abc123"
const tools = { question: false, task: true, call_omo_agent: true }
//#when
setSessionTools(sessionID, tools)
const result = getSessionTools(sessionID)
//#then
expect(result).toEqual({ question: false, task: true, call_omo_agent: true })
})
test("overwrites existing tools for same session", () => {
//#given
const sessionID = "ses_abc123"
setSessionTools(sessionID, { question: false })
//#when
setSessionTools(sessionID, { question: true, task: false })
const result = getSessionTools(sessionID)
//#then
expect(result).toEqual({ question: true, task: false })
})
test("clearSessionTools removes all entries", () => {
//#given
setSessionTools("ses_1", { question: false })
setSessionTools("ses_2", { task: true })
//#when
clearSessionTools()
//#then
expect(getSessionTools("ses_1")).toBeUndefined()
expect(getSessionTools("ses_2")).toBeUndefined()
})
test("returns a copy, not a reference", () => {
//#given
const sessionID = "ses_abc123"
const tools = { question: false }
setSessionTools(sessionID, tools)
//#when
const result = getSessionTools(sessionID)!
result.question = true
//#then
expect(getSessionTools(sessionID)).toEqual({ question: false })
})
})

View File

@@ -0,0 +1,14 @@
const store = new Map<string, Record<string, boolean>>()
export function setSessionTools(sessionID: string, tools: Record<string, boolean>): void {
store.set(sessionID, { ...tools })
}
export function getSessionTools(sessionID: string): Record<string, boolean> | undefined {
const tools = store.get(sessionID)
return tools ? { ...tools } : undefined
}
export function clearSessionTools(): void {
store.clear()
}

View File

@@ -1,87 +0,0 @@
/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { mkdtempSync, rmSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { randomUUID } from "node:crypto"
import { createTeamConfig, deleteTeamData } from "./team-config-store"
import { createReadConfigTool } from "./config-tools"
describe("read_config tool", () => {
let originalCwd: string
let tempProjectDir: string
let teamName: string
const TEST_SESSION_ID = "test-session-123"
const TEST_ABORT_CONTROLLER = new AbortController()
const TEST_CONTEXT = {
sessionID: TEST_SESSION_ID,
messageID: "test-message-123",
agent: "test-agent",
abort: TEST_ABORT_CONTROLLER.signal,
}
beforeEach(() => {
originalCwd = process.cwd()
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-config-tools-"))
process.chdir(tempProjectDir)
teamName = `test-team-${randomUUID()}`
})
afterEach(() => {
try {
deleteTeamData(teamName)
} catch {
// ignore
}
process.chdir(originalCwd)
rmSync(tempProjectDir, { recursive: true, force: true })
})
describe("read config action", () => {
test("returns team config when team exists", async () => {
//#given
const config = createTeamConfig(teamName, "Test team", TEST_SESSION_ID, "/tmp", "claude-opus-4-6")
const tool = createReadConfigTool()
//#when
const resultStr = await tool.execute({
team_name: teamName,
}, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result.name).toBe(teamName)
expect(result.description).toBe("Test team")
expect(result.members).toHaveLength(1)
expect(result.members[0].name).toBe("team-lead")
expect(result.members[0].agentType).toBe("team-lead")
})
test("returns error for non-existent team", async () => {
//#given
const tool = createReadConfigTool()
//#when
const resultStr = await tool.execute({
team_name: "nonexistent-team-12345",
}, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result).toHaveProperty("error")
expect(result.error).toBe("team_not_found")
})
test("requires team_name parameter", async () => {
//#given
const tool = createReadConfigTool()
//#when
const resultStr = await tool.execute({}, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result).toHaveProperty("error")
})
})
})

View File

@@ -1,24 +0,0 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { readTeamConfig } from "./team-config-store"
import { ReadConfigInputSchema } from "./types"
export function createReadConfigTool(): ToolDefinition {
return tool({
description: "Read team configuration and member list.",
args: {
team_name: tool.schema.string().describe("Team name"),
},
execute: async (args: Record<string, unknown>): Promise<string> => {
try {
const input = ReadConfigInputSchema.parse(args)
const config = readTeamConfig(input.team_name)
if (!config) {
return JSON.stringify({ error: "team_not_found" })
}
return JSON.stringify(config)
} catch (error) {
return JSON.stringify({ error: error instanceof Error ? error.message : "read_config_failed" })
}
},
})
}

View File

@@ -1,190 +0,0 @@
/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { mkdtempSync, rmSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import type { BackgroundManager } from "../../features/background-agent"
import { createAgentTeamsTools } from "./tools"
interface LaunchCall {
description: string
prompt: string
agent: string
parentSessionID: string
parentMessageID: string
parentAgent?: string
parentModel?: {
providerID: string
modelID: string
variant?: string
}
}
interface ResumeCall {
sessionId: string
prompt: string
parentSessionID: string
parentMessageID: string
parentAgent?: string
parentModel?: {
providerID: string
modelID: string
variant?: string
}
}
interface ToolContextLike {
sessionID: string
messageID: string
abort: AbortSignal
agent?: string
}
function createMockManager(): {
manager: BackgroundManager
launchCalls: LaunchCall[]
resumeCalls: ResumeCall[]
} {
const launchCalls: LaunchCall[] = []
const resumeCalls: ResumeCall[] = []
const launchedTasks = new Map<string, { id: string; sessionID: string }>()
let launchCount = 0
const manager = {
launch: async (args: LaunchCall) => {
launchCount += 1
launchCalls.push(args)
const task = { id: `bg-${launchCount}`, sessionID: `ses-worker-${launchCount}` }
launchedTasks.set(task.id, task)
return task
},
getTask: (taskId: string) => launchedTasks.get(taskId),
resume: async (args: ResumeCall) => {
resumeCalls.push(args)
return { id: `resume-${resumeCalls.length}` }
},
} as unknown as BackgroundManager
return { manager, launchCalls, resumeCalls }
}
async function executeJsonTool(
tools: ReturnType<typeof createAgentTeamsTools>,
toolName: keyof ReturnType<typeof createAgentTeamsTools>,
args: Record<string, unknown>,
context: ToolContextLike,
): Promise<unknown> {
const output = await tools[toolName].execute(args, context as any)
return JSON.parse(output)
}
describe("agent-teams delegation consistency", () => {
let originalCwd: string
let tempProjectDir: string
beforeEach(() => {
originalCwd = process.cwd()
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-consistency-"))
process.chdir(tempProjectDir)
})
afterEach(() => {
process.chdir(originalCwd)
rmSync(tempProjectDir, { recursive: true, force: true })
})
test("team delegation forwards parent context like normal delegate-task", async () => {
//#given
const { manager, launchCalls, resumeCalls } = createMockManager()
const tools = createAgentTeamsTools(manager)
const leadContext: ToolContextLike = {
sessionID: "ses-main",
messageID: "msg-main",
abort: new AbortController().signal,
agent: "sisyphus",
}
await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext)
//#when
const spawnResult = await executeJsonTool(
tools,
"spawn_teammate",
{
team_name: "core",
name: "worker_1",
prompt: "Handle release prep",
category: "quick",
},
leadContext,
) as { error?: string }
//#then
expect(spawnResult.error).toBeUndefined()
expect(launchCalls).toHaveLength(1)
expect(launchCalls[0].parentAgent).toBe("sisyphus")
expect("parentModel" in launchCalls[0]).toBe(true)
//#when
const messageResult = await executeJsonTool(
tools,
"send_message",
{
team_name: "core",
type: "message",
recipient: "worker_1",
summary: "sync",
content: "Please update status.",
},
leadContext,
) as { error?: string }
//#then
expect(messageResult.error).toBeUndefined()
expect(resumeCalls).toHaveLength(1)
expect(resumeCalls[0].parentAgent).toBe("sisyphus")
expect("parentModel" in resumeCalls[0]).toBe(true)
})
test("send_message accepts teammate agent_id as recipient", async () => {
//#given
const { manager, resumeCalls } = createMockManager()
const tools = createAgentTeamsTools(manager)
const leadContext: ToolContextLike = {
sessionID: "ses-main",
messageID: "msg-main",
abort: new AbortController().signal,
}
await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext)
await executeJsonTool(
tools,
"spawn_teammate",
{
team_name: "core",
name: "worker_1",
prompt: "Handle release prep",
category: "quick",
},
leadContext,
)
//#when
const messageResult = await executeJsonTool(
tools,
"send_message",
{
team_name: "core",
type: "message",
recipient: "worker_1@core",
summary: "sync",
content: "Please update status.",
},
leadContext,
) as { error?: string }
//#then
expect(messageResult.error).toBeUndefined()
expect(resumeCalls).toHaveLength(1)
})
})

View File

@@ -1,61 +0,0 @@
import { randomUUID } from "node:crypto"
import { InboxMessageSchema } from "./types"
import { appendInboxMessage } from "./inbox-store"
function nowIso(): string {
return new Date().toISOString()
}
const STRUCTURED_TYPE_MAP: Record<string, string> = {
shutdown_request: "shutdown_request",
shutdown_approved: "shutdown_response",
shutdown_rejected: "shutdown_response",
plan_approved: "plan_approval_response",
plan_rejected: "plan_approval_response",
}
export function buildShutdownRequestId(recipient: string): string {
return `shutdown-${recipient}-${randomUUID().slice(0, 8)}`
}
export function sendPlainInboxMessage(
teamName: string,
sender: string,
recipient: string,
content: string,
summary: string,
_color?: string,
): void {
const message = InboxMessageSchema.parse({
id: randomUUID(),
type: "message",
sender,
recipient,
content,
summary,
timestamp: nowIso(),
read: false,
})
appendInboxMessage(teamName, recipient, message)
}
export function sendStructuredInboxMessage(
teamName: string,
sender: string,
recipient: string,
data: Record<string, unknown>,
summaryType: string,
): void {
const messageType = STRUCTURED_TYPE_MAP[summaryType] ?? "message"
const message = InboxMessageSchema.parse({
id: randomUUID(),
type: messageType,
sender,
recipient,
content: JSON.stringify(data),
summary: summaryType,
timestamp: nowIso(),
read: false,
})
appendInboxMessage(teamName, recipient, message)
}

View File

@@ -1,59 +0,0 @@
/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { appendInboxMessage, ensureInbox, readInbox } from "./inbox-store"
import { getTeamInboxPath } from "./paths"
describe("agent-teams inbox store", () => {
let originalCwd: string
let tempProjectDir: string
beforeEach(() => {
originalCwd = process.cwd()
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-inbox-store-"))
process.chdir(tempProjectDir)
})
afterEach(() => {
process.chdir(originalCwd)
rmSync(tempProjectDir, { recursive: true, force: true })
})
test("readInbox fails on malformed inbox JSON without overwriting file", () => {
//#given
ensureInbox("core", "team-lead")
const inboxPath = getTeamInboxPath("core", "team-lead")
writeFileSync(inboxPath, "{", "utf-8")
//#when
const readMalformedInbox = () => readInbox("core", "team-lead", false, false)
//#then
expect(readMalformedInbox).toThrow("team_inbox_parse_failed")
expect(readFileSync(inboxPath, "utf-8")).toBe("{")
})
test("appendInboxMessage fails on schema-invalid inbox JSON without overwriting file", () => {
//#given
ensureInbox("core", "team-lead")
const inboxPath = getTeamInboxPath("core", "team-lead")
writeFileSync(inboxPath, JSON.stringify({ invalid: true }), "utf-8")
//#when
const appendIntoInvalidInbox = () => {
appendInboxMessage("core", "team-lead", {
from: "team-lead",
text: "hello",
timestamp: new Date().toISOString(),
read: false,
summary: "note",
})
}
//#then
expect(appendIntoInvalidInbox).toThrow("team_inbox_schema_invalid")
expect(readFileSync(inboxPath, "utf-8")).toBe(JSON.stringify({ invalid: true }))
})
})

View File

@@ -1,197 +0,0 @@
import { existsSync, readFileSync, unlinkSync } from "node:fs"
import { z } from "zod"
import { acquireLock, ensureDir, writeJsonAtomic } from "../../features/claude-tasks/storage"
import { getTeamInboxPath } from "./paths"
import type { InboxMessage } from "./types"
import { InboxMessageSchema } from "./types"
const InboxMessageListSchema = z.array(InboxMessageSchema)
function assertValidTeamName(teamName: string): void {
const errors: string[] = []
if (!/^[A-Za-z0-9_-]+$/.test(teamName)) {
errors.push("Team name must contain only letters, numbers, hyphens, and underscores")
}
if (teamName.length > 64) {
errors.push("Team name must be at most 64 characters")
}
if (errors.length > 0) {
throw new Error(`Invalid team name: ${errors.join(", ")}`)
}
}
function assertValidAgentName(agentName: string): void {
if (!agentName || agentName.length === 0) {
throw new Error("Agent name must not be empty")
}
}
function getTeamInboxDirFromName(teamName: string): string {
const { dirname } = require("node:path")
return dirname(getTeamInboxPath(teamName, "dummy"))
}
function withInboxLock<T>(teamName: string, operation: () => T): T {
assertValidTeamName(teamName)
const inboxDir = getTeamInboxDirFromName(teamName)
ensureDir(inboxDir)
const lock = acquireLock(inboxDir)
if (!lock.acquired) {
throw new Error("inbox_lock_unavailable")
}
try {
return operation()
} finally {
lock.release()
}
}
function parseInboxFile(content: string): InboxMessage[] {
let parsed: unknown
try {
parsed = JSON.parse(content)
} catch {
throw new Error("team_inbox_parse_failed")
}
const result = InboxMessageListSchema.safeParse(parsed)
if (!result.success) {
throw new Error("team_inbox_schema_invalid")
}
return result.data
}
function readInboxMessages(teamName: string, agentName: string): InboxMessage[] {
assertValidTeamName(teamName)
assertValidAgentName(agentName)
const path = getTeamInboxPath(teamName, agentName)
if (!existsSync(path)) {
return []
}
return parseInboxFile(readFileSync(path, "utf-8"))
}
function writeInboxMessages(teamName: string, agentName: string, messages: InboxMessage[]): void {
assertValidTeamName(teamName)
assertValidAgentName(agentName)
const path = getTeamInboxPath(teamName, agentName)
writeJsonAtomic(path, messages)
}
export function ensureInbox(teamName: string, agentName: string): void {
assertValidTeamName(teamName)
assertValidAgentName(agentName)
withInboxLock(teamName, () => {
const path = getTeamInboxPath(teamName, agentName)
if (!existsSync(path)) {
writeJsonAtomic(path, [])
}
})
}
export function appendInboxMessage(teamName: string, agentName: string, message: InboxMessage): void {
assertValidTeamName(teamName)
assertValidAgentName(agentName)
withInboxLock(teamName, () => {
const path = getTeamInboxPath(teamName, agentName)
const messages = existsSync(path) ? parseInboxFile(readFileSync(path, "utf-8")) : []
messages.push(InboxMessageSchema.parse(message))
writeInboxMessages(teamName, agentName, messages)
})
}
export function readInbox(teamName: string, agentName: string, unreadOnly = false, markAsRead = false): InboxMessage[] {
return withInboxLock(teamName, () => {
const messages = readInboxMessages(teamName, agentName)
const selectedIndexes = new Set<number>()
const selected = unreadOnly
? messages.filter((message, index) => {
if (!message.read) {
selectedIndexes.add(index)
return true
}
return false
})
: messages.map((message, index) => {
selectedIndexes.add(index)
return message
})
if (!markAsRead || selected.length === 0) {
return selected
}
let changed = false
const updated = messages.map((message, index) => {
if (selectedIndexes.has(index) && !message.read) {
changed = true
return { ...message, read: true }
}
return message
})
if (changed) {
writeInboxMessages(teamName, agentName, updated)
}
return updated.filter((_, index) => selectedIndexes.has(index))
})
}
export function markMessagesRead(teamName: string, agentName: string, messageIds: string[]): void {
assertValidTeamName(teamName)
assertValidAgentName(agentName)
if (messageIds.length === 0) {
return
}
withInboxLock(teamName, () => {
const messages = readInboxMessages(teamName, agentName)
const idsToMark = new Set(messageIds)
const updated = messages.map((message) => {
if (idsToMark.has(message.id) && !message.read) {
return { ...message, read: true }
}
return message
})
const changed = updated.some((msg, index) => msg.read !== messages[index].read)
if (changed) {
writeInboxMessages(teamName, agentName, updated)
}
})
}
export function deleteInbox(teamName: string, agentName: string): void {
assertValidTeamName(teamName)
assertValidAgentName(agentName)
withInboxLock(teamName, () => {
const path = getTeamInboxPath(teamName, agentName)
if (existsSync(path)) {
unlinkSync(path)
}
})
}
export const clearInbox = deleteInbox
export { buildShutdownRequestId, sendPlainInboxMessage, sendStructuredInboxMessage } from "./inbox-message-sender"

View File

@@ -1,182 +0,0 @@
/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { mkdtempSync, rmSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { randomUUID } from "node:crypto"
import { appendInboxMessage, ensureInbox } from "./inbox-store"
import { deleteTeamData } from "./team-config-store"
import { createReadInboxTool } from "./inbox-tools"
describe("read_inbox tool", () => {
let originalCwd: string
let tempProjectDir: string
let teamName: string
const TEST_SESSION_ID = "test-session-123"
const TEST_ABORT_CONTROLLER = new AbortController()
const TEST_CONTEXT = {
sessionID: TEST_SESSION_ID,
messageID: "test-message-123",
agent: "test-agent",
abort: TEST_ABORT_CONTROLLER.signal,
}
beforeEach(() => {
originalCwd = process.cwd()
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-inbox-tools-"))
process.chdir(tempProjectDir)
teamName = `test-team-${randomUUID()}`
})
afterEach(() => {
try {
deleteTeamData(teamName)
} catch {
// ignore
}
process.chdir(originalCwd)
rmSync(tempProjectDir, { recursive: true, force: true })
})
afterEach(() => {
process.chdir(originalCwd)
rmSync(tempProjectDir, { recursive: true, force: true })
})
describe("read inbox action", () => {
test("returns all messages when no filters", async () => {
//#given
ensureInbox(teamName, "team-lead")
appendInboxMessage(teamName, "team-lead", {
id: "msg-1",
type: "message",
sender: "user",
recipient: "team-lead",
content: "Hello",
timestamp: new Date().toISOString(),
read: false,
})
appendInboxMessage(teamName, "team-lead", {
id: "msg-2",
type: "message",
sender: "user",
recipient: "team-lead",
content: "World",
timestamp: new Date().toISOString(),
read: true,
})
const tool = createReadInboxTool()
//#when
const resultStr = await tool.execute({
team_name: teamName,
agent_name: "team-lead",
}, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result).toHaveLength(2)
expect(result[0].id).toBe("msg-1")
expect(result[1].id).toBe("msg-2")
})
test("returns only unread messages when unread_only is true", async () => {
//#given
ensureInbox(teamName, "team-lead")
appendInboxMessage(teamName, "team-lead", {
id: "msg-1",
type: "message",
sender: "user",
recipient: "team-lead",
content: "Hello",
timestamp: new Date().toISOString(),
read: false,
})
appendInboxMessage(teamName, "team-lead", {
id: "msg-2",
type: "message",
sender: "user",
recipient: "team-lead",
content: "World",
timestamp: new Date().toISOString(),
read: true,
})
const tool = createReadInboxTool()
//#when
const resultStr = await tool.execute({
team_name: teamName,
agent_name: "team-lead",
unread_only: true,
}, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result).toHaveLength(1)
expect(result[0].id).toBe("msg-1")
})
test("marks messages as read when mark_as_read is true", async () => {
//#given
ensureInbox(teamName, "team-lead")
appendInboxMessage(teamName, "team-lead", {
id: "msg-1",
type: "message",
sender: "user",
recipient: "team-lead",
content: "Hello",
timestamp: new Date().toISOString(),
read: false,
})
const tool = createReadInboxTool()
//#when
await tool.execute({
team_name: teamName,
agent_name: "team-lead",
mark_as_read: true,
}, TEST_CONTEXT)
// Read again to check if marked as read
const resultStr = await tool.execute({
team_name: teamName,
agent_name: "team-lead",
unread_only: true,
}, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result).toHaveLength(0) // Should be marked as read
})
test("returns empty array for non-existent inbox", async () => {
//#given
const tool = createReadInboxTool()
//#when
const resultStr = await tool.execute({
team_name: "nonexistent",
agent_name: "team-lead",
}, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result).toEqual([])
})
test("requires team_name and agent_name parameters", async () => {
//#given
const tool = createReadInboxTool()
//#when
const resultStr = await tool.execute({}, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result).toHaveProperty("error")
})
})
})

View File

@@ -1,29 +0,0 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { readInbox } from "./inbox-store"
import { ReadInboxInputSchema } from "./types"
export function createReadInboxTool(): ToolDefinition {
return tool({
description: "Read inbox messages for a team member.",
args: {
team_name: tool.schema.string().describe("Team name"),
agent_name: tool.schema.string().describe("Member name"),
unread_only: tool.schema.boolean().optional().describe("Return only unread messages"),
mark_as_read: tool.schema.boolean().optional().describe("Mark returned messages as read"),
},
execute: async (args: Record<string, unknown>): Promise<string> => {
try {
const input = ReadInboxInputSchema.parse(args)
const messages = readInbox(
input.team_name,
input.agent_name,
input.unread_only ?? false,
input.mark_as_read ?? false,
)
return JSON.stringify(messages)
} catch (error) {
return JSON.stringify({ error: error instanceof Error ? error.message : "read_inbox_failed" })
}
},
})
}

View File

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

View File

@@ -1,467 +0,0 @@
/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { randomUUID } from "node:crypto"
import { mkdtempSync, rmSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import type { BackgroundManager } from "../../features/background-agent"
import { readInbox } from "./inbox-store"
import { createAgentTeamsTools } from "./tools"
import { readTeamConfig, upsertTeammate, writeTeamConfig } from "./team-config-store"
interface TestToolContext {
sessionID: string
messageID: string
agent: string
abort: AbortSignal
}
interface ResumeCall {
sessionId: string
prompt: string
}
function createContext(sessionID = "ses-main"): TestToolContext {
return {
sessionID,
messageID: "msg-main",
agent: "sisyphus",
abort: new AbortController().signal,
}
}
async function executeJsonTool(
tools: ReturnType<typeof createAgentTeamsTools>,
toolName: keyof ReturnType<typeof createAgentTeamsTools>,
args: Record<string, unknown>,
context: TestToolContext,
): Promise<unknown> {
const output = await tools[toolName].execute(args, context)
return JSON.parse(output)
}
function uniqueTeam(): string {
return `msg-${randomUUID().slice(0, 8)}`
}
function createMockManager(): { manager: BackgroundManager; resumeCalls: ResumeCall[] } {
const resumeCalls: ResumeCall[] = []
let launchCount = 0
const manager = {
launch: async () => {
launchCount += 1
return { id: `bg-${launchCount}`, sessionID: `ses-worker-${launchCount}` }
},
getTask: () => undefined,
resume: async (args: ResumeCall) => {
resumeCalls.push(args)
return { id: `resume-${resumeCalls.length}` }
},
} as unknown as BackgroundManager
return { manager, resumeCalls }
}
async function setupTeamWithWorker(
_tools: ReturnType<typeof createAgentTeamsTools>,
context: TestToolContext,
teamName = "core",
workerName = "worker_1",
): Promise<void> {
await executeJsonTool(_tools, "team_create", { team_name: teamName }, context)
const config = readTeamConfig(teamName)
if (config) {
const teammate = {
agentId: `agent-${randomUUID()}`,
name: workerName,
agentType: "teammate" as const,
category: "quick",
model: "default",
prompt: "Handle tasks",
joinedAt: new Date().toISOString(),
color: "#FF5733",
cwd: process.cwd(),
planModeRequired: false,
subscriptions: [],
backendType: "native" as const,
isActive: true,
}
const updatedConfig = upsertTeammate(config, teammate)
writeTeamConfig(teamName, updatedConfig)
}
}
async function addTeammateManually(teamName: string, workerName: string): Promise<void> {
const config = readTeamConfig(teamName)
if (config) {
const teammate = {
agentId: `agent-${randomUUID()}`,
name: workerName,
agentType: "teammate" as const,
category: "quick",
model: "default",
prompt: "Handle tasks",
joinedAt: new Date().toISOString(),
color: "#FF5733",
cwd: process.cwd(),
planModeRequired: false,
subscriptions: [],
backendType: "native" as const,
isActive: true,
}
const updatedConfig = upsertTeammate(config, teammate)
writeTeamConfig(teamName, updatedConfig)
}
}
describe("agent-teams messaging tools", () => {
let originalCwd: string
let tempProjectDir: string
beforeEach(() => {
originalCwd = process.cwd()
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-messaging-"))
process.chdir(tempProjectDir)
})
afterEach(() => {
process.chdir(originalCwd)
rmSync(tempProjectDir, { recursive: true, force: true })
})
describe("message type", () => {
test("delivers message to recipient inbox", async () => {
//#given
const tn = uniqueTeam()
const { manager } = createMockManager()
const tools = createAgentTeamsTools(manager)
const leadContext = createContext()
await setupTeamWithWorker(tools, leadContext, tn)
//#when
const result = await executeJsonTool(
tools,
"send_message",
{
team_name: tn,
type: "message",
recipient: "worker_1",
content: "Please update status.",
summary: "status_request",
},
leadContext,
) as { success?: boolean; message?: string }
//#then
expect(result.success).toBe(true)
expect(result.message).toBe("message_sent:worker_1")
const inbox = readInbox(tn, "worker_1")
const delivered = inbox.filter((m) => m.summary === "status_request")
expect(delivered.length).toBeGreaterThanOrEqual(1)
expect(delivered[0]?.sender).toBe("team-lead")
expect(delivered[0]?.content).toBe("Please update status.")
})
test("rejects message to nonexistent recipient", async () => {
//#given
const tn = uniqueTeam()
const { manager } = createMockManager()
const tools = createAgentTeamsTools(manager)
const leadContext = createContext()
await setupTeamWithWorker(tools, leadContext, tn)
//#when
const result = await executeJsonTool(
tools,
"send_message",
{ team_name: tn, type: "message", recipient: "nonexistent", content: "hello", summary: "test" },
leadContext,
) as { error?: string }
//#then
expect(result.error).toBe("message_recipient_not_found")
})
test("rejects recipient with team suffix mismatch", async () => {
//#given
const tn = uniqueTeam()
const { manager, resumeCalls } = createMockManager()
const tools = createAgentTeamsTools(manager)
const leadContext = createContext()
await setupTeamWithWorker(tools, leadContext, tn)
//#when
const result = await executeJsonTool(
tools,
"send_message",
{ team_name: tn, type: "message", recipient: "worker_1@other-team", summary: "sync", content: "hi" },
leadContext,
) as { error?: string }
//#then
expect(result.error).toBe("recipient_team_mismatch")
expect(resumeCalls).toHaveLength(0)
})
test("rejects recipient with empty team suffix", async () => {
//#given
const tn = uniqueTeam()
const { manager, resumeCalls } = createMockManager()
const tools = createAgentTeamsTools(manager)
const leadContext = createContext()
await setupTeamWithWorker(tools, leadContext, tn)
//#when
const result = await executeJsonTool(
tools,
"send_message",
{ team_name: tn, type: "message", recipient: "worker_1@", summary: "sync", content: "hi" },
leadContext,
) as { error?: string }
//#then
expect(result.error).toBe("recipient_team_invalid")
expect(resumeCalls).toHaveLength(0)
})
})
describe("broadcast type", () => {
test("writes to all teammate inboxes", async () => {
//#given
const tn = uniqueTeam()
const { manager } = createMockManager()
const tools = createAgentTeamsTools(manager)
const leadContext = createContext()
await executeJsonTool(tools, "team_create", { team_name: tn }, leadContext)
for (const name of ["worker_1", "worker_2"]) {
await addTeammateManually(tn, name)
}
//#when
const result = await executeJsonTool(
tools,
"send_message",
{ team_name: tn, type: "broadcast", summary: "sync", content: "Status update needed" },
leadContext,
) as { success?: boolean; message?: string }
//#then
expect(result.success).toBe(true)
expect(result.message).toBe("broadcast_sent:2")
for (const name of ["worker_1", "worker_2"]) {
const inbox = readInbox(tn, name)
const broadcastMessages = inbox.filter((m) => m.summary === "sync")
expect(broadcastMessages.length).toBeGreaterThanOrEqual(1)
}
})
test("rejects broadcast without summary", async () => {
//#given
const tn = uniqueTeam()
const { manager } = createMockManager()
const tools = createAgentTeamsTools(manager)
const leadContext = createContext()
await setupTeamWithWorker(tools, leadContext, tn)
//#when
const result = await executeJsonTool(
tools,
"send_message",
{ team_name: tn, type: "broadcast", content: "hello" },
leadContext,
) as { error?: string }
//#then
expect(result.error).toBe("broadcast_requires_summary")
})
})
describe("shutdown_request type", () => {
test("sends shutdown request and returns request_id", async () => {
//#given
const tn = uniqueTeam()
const { manager } = createMockManager()
const tools = createAgentTeamsTools(manager)
const leadContext = createContext()
await setupTeamWithWorker(tools, leadContext, tn)
//#when
const result = await executeJsonTool(
tools,
"send_message",
{ team_name: tn, type: "shutdown_request", recipient: "worker_1", content: "Work completed" },
leadContext,
) as { success?: boolean; request_id?: string; target?: string }
//#then
expect(result.success).toBe(true)
expect(result.request_id).toMatch(/^shutdown-worker_1-/)
expect(result.target).toBe("worker_1")
})
test("rejects shutdown targeting team-lead", async () => {
//#given
const tn = uniqueTeam()
const { manager } = createMockManager()
const tools = createAgentTeamsTools(manager)
const leadContext = createContext()
await setupTeamWithWorker(tools, leadContext, tn)
//#when
const result = await executeJsonTool(
tools,
"send_message",
{ team_name: tn, type: "shutdown_request", recipient: "team-lead" },
leadContext,
) as { error?: string }
//#then
expect(result.error).toBe("cannot_shutdown_team_lead")
})
})
describe("shutdown_response type", () => {
test("sends approved shutdown response to team-lead inbox", async () => {
//#given
const tn = uniqueTeam()
const { manager } = createMockManager()
const tools = createAgentTeamsTools(manager)
const leadContext = createContext()
await setupTeamWithWorker(tools, leadContext, tn)
//#when
const result = await executeJsonTool(
tools,
"send_message",
{ team_name: tn, type: "shutdown_response", request_id: "shutdown-worker_1-abc12345", approve: true },
leadContext,
) as { success?: boolean; message?: string }
//#then
expect(result.success).toBe(true)
expect(result.message).toBe("shutdown_approved:shutdown-worker_1-abc12345")
const leadInbox = readInbox(tn, "team-lead")
const shutdownMessages = leadInbox.filter((m) => m.summary === "shutdown_approved")
expect(shutdownMessages.length).toBeGreaterThanOrEqual(1)
})
test("sends rejected shutdown response", async () => {
//#given
const tn = uniqueTeam()
const { manager } = createMockManager()
const tools = createAgentTeamsTools(manager)
const leadContext = createContext()
await setupTeamWithWorker(tools, leadContext, tn)
//#when
const result = await executeJsonTool(
tools,
"send_message",
{
team_name: tn,
type: "shutdown_response",
request_id: "shutdown-worker_1-abc12345",
approve: false,
content: "Still working on it",
},
leadContext,
) as { success?: boolean; message?: string }
//#then
expect(result.success).toBe(true)
expect(result.message).toBe("shutdown_rejected:shutdown-worker_1-abc12345")
})
})
describe("plan_approval_response type", () => {
test("sends approved plan response", async () => {
//#given
const tn = uniqueTeam()
const { manager } = createMockManager()
const tools = createAgentTeamsTools(manager)
const leadContext = createContext()
await setupTeamWithWorker(tools, leadContext, tn)
//#when
const result = await executeJsonTool(
tools,
"send_message",
{ team_name: tn, type: "plan_approval_response", request_id: "plan-req-001", approve: true, recipient: "worker_1" },
leadContext,
) as { success?: boolean; message?: string }
//#then
expect(result.success).toBe(true)
expect(result.message).toBe("plan_approved:worker_1")
})
test("sends rejected plan response with content", async () => {
//#given
const tn = uniqueTeam()
const { manager } = createMockManager()
const tools = createAgentTeamsTools(manager)
const leadContext = createContext()
await setupTeamWithWorker(tools, leadContext, tn)
//#when
const result = await executeJsonTool(
tools,
"send_message",
{
team_name: tn,
type: "plan_approval_response",
request_id: "plan-req-002",
approve: false,
recipient: "worker_1",
content: "Need more details",
},
leadContext,
) as { success?: boolean; message?: string }
//#then
expect(result.success).toBe(true)
expect(result.message).toBe("plan_rejected:worker_1")
})
})
describe("authorization", () => {
test("rejects message from unauthorized session", async () => {
//#given
const tn = uniqueTeam()
const { manager } = createMockManager()
const tools = createAgentTeamsTools(manager)
const leadContext = createContext()
await setupTeamWithWorker(tools, leadContext, tn)
//#when
const result = await executeJsonTool(
tools,
"send_message",
{ team_name: tn, type: "message", recipient: "worker_1", content: "hello", summary: "test" },
createContext("ses-intruder"),
) as { error?: string }
//#then
expect(result.error).toBe("unauthorized_sender_session")
})
test("rejects message to nonexistent team", async () => {
//#given
const { manager } = createMockManager()
const tools = createAgentTeamsTools(manager)
//#when
const result = await executeJsonTool(
tools,
"send_message",
{ team_name: "nonexistent-xyz", type: "message", recipient: "w", content: "hello", summary: "test" },
createContext(),
) as { error?: string }
//#then
expect(result.error).toBe("team_not_found")
})
})
})

View File

@@ -1,282 +0,0 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import type { BackgroundManager } from "../../features/background-agent"
import { buildShutdownRequestId, readInbox, sendPlainInboxMessage, sendStructuredInboxMessage } from "./inbox-store"
import { getTeamMember, listTeammates, readTeamConfigOrThrow } from "./team-config-store"
import { validateAgentNameOrLead, validateTeamName } from "./name-validation"
import { resumeTeammateWithMessage } from "./teammate-runtime"
import {
TeamConfig,
TeamReadInboxInputSchema,
TeamSendMessageInputSchema,
TeamToolContext,
isTeammateMember,
} from "./types"
function nowIso(): string {
return new Date().toISOString()
}
function validateRecipientTeam(recipient: unknown, teamName: string): string | null {
if (typeof recipient !== "string") {
return null
}
const trimmed = recipient.trim()
const atIndex = trimmed.indexOf("@")
if (atIndex <= 0) {
return null
}
const specifiedTeam = trimmed.slice(atIndex + 1).trim()
if (!specifiedTeam) {
return "recipient_team_invalid"
}
if (specifiedTeam === teamName) {
return null
}
return "recipient_team_mismatch"
}
function resolveSenderFromContext(config: TeamConfig, context: TeamToolContext): string | null {
if (context.sessionID === config.leadSessionId) {
return "team-lead"
}
const matchedMember = config.members.find((member) => isTeammateMember(member) && member.sessionID === context.sessionID)
return matchedMember?.name ?? null
}
export function createSendMessageTool(manager: BackgroundManager): ToolDefinition {
return tool({
description: "Send direct or broadcast team messages and protocol responses.",
args: {
team_name: tool.schema.string().describe("Team name"),
type: tool.schema.enum(["message", "broadcast", "shutdown_request", "shutdown_response", "plan_approval_response"]),
recipient: tool.schema.string().optional().describe("Message recipient"),
content: tool.schema.string().optional().describe("Message body"),
summary: tool.schema.string().optional().describe("Short summary"),
request_id: tool.schema.string().optional().describe("Protocol request id"),
approve: tool.schema.boolean().optional().describe("Approval flag"),
sender: tool.schema
.string()
.optional()
.describe("Sender name inferred from calling session; explicit value must match resolved sender."),
},
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
try {
const input = TeamSendMessageInputSchema.parse(args)
const teamError = validateTeamName(input.team_name)
if (teamError) {
return JSON.stringify({ error: teamError })
}
const recipientTeamError = validateRecipientTeam(args.recipient, input.team_name)
if (recipientTeamError) {
return JSON.stringify({ error: recipientTeamError })
}
const requestedSender = input.sender
const senderError = requestedSender ? validateAgentNameOrLead(requestedSender) : null
if (senderError) {
return JSON.stringify({ error: senderError })
}
const config = readTeamConfigOrThrow(input.team_name)
const actor = resolveSenderFromContext(config, context)
if (!actor) {
return JSON.stringify({ error: "unauthorized_sender_session" })
}
if (requestedSender && requestedSender !== actor) {
return JSON.stringify({ error: "sender_context_mismatch" })
}
const sender = requestedSender ?? actor
const memberNames = new Set(config.members.map((member) => member.name))
if (sender !== "team-lead" && !memberNames.has(sender)) {
return JSON.stringify({ error: "invalid_sender" })
}
if (input.type === "message") {
if (!input.recipient || !input.summary || !input.content) {
return JSON.stringify({ error: "message_requires_recipient_summary_content" })
}
if (!memberNames.has(input.recipient)) {
return JSON.stringify({ error: "message_recipient_not_found" })
}
const targetMember = getTeamMember(config, input.recipient)
const color = targetMember && isTeammateMember(targetMember) ? targetMember.color : undefined
sendPlainInboxMessage(input.team_name, sender, input.recipient, input.content, input.summary, color)
if (targetMember && isTeammateMember(targetMember)) {
await resumeTeammateWithMessage(manager, context, input.team_name, targetMember, input.summary, input.content)
}
return JSON.stringify({ success: true, message: `message_sent:${input.recipient}` })
}
if (input.type === "broadcast") {
if (!input.summary) {
return JSON.stringify({ error: "broadcast_requires_summary" })
}
const broadcastSummary = input.summary
const teammates = listTeammates(config)
for (const teammate of teammates) {
sendPlainInboxMessage(input.team_name, sender, teammate.name, input.content ?? "", broadcastSummary)
}
await Promise.allSettled(
teammates.map((teammate) =>
resumeTeammateWithMessage(manager, context, input.team_name, teammate, broadcastSummary, input.content ?? ""),
),
)
return JSON.stringify({ success: true, message: `broadcast_sent:${teammates.length}` })
}
if (input.type === "shutdown_request") {
if (!input.recipient) {
return JSON.stringify({ error: "shutdown_request_requires_recipient" })
}
if (input.recipient === "team-lead") {
return JSON.stringify({ error: "cannot_shutdown_team_lead" })
}
const targetMember = getTeamMember(config, input.recipient)
if (!targetMember || !isTeammateMember(targetMember)) {
return JSON.stringify({ error: "shutdown_recipient_not_found" })
}
const requestId = buildShutdownRequestId(input.recipient)
sendStructuredInboxMessage(
input.team_name,
sender,
input.recipient,
{
type: "shutdown_request",
requestId,
from: sender,
reason: input.content ?? "",
timestamp: nowIso(),
},
"shutdown_request",
)
await resumeTeammateWithMessage(
manager,
context,
input.team_name,
targetMember,
"shutdown_request",
input.content ?? "Shutdown requested",
)
return JSON.stringify({ success: true, request_id: requestId, target: input.recipient })
}
if (input.type === "shutdown_response") {
if (!input.request_id) {
return JSON.stringify({ error: "shutdown_response_requires_request_id" })
}
if (input.approve) {
sendStructuredInboxMessage(
input.team_name,
sender,
"team-lead",
{
type: "shutdown_approved",
requestId: input.request_id,
from: sender,
timestamp: nowIso(),
backendType: "native",
},
"shutdown_approved",
)
return JSON.stringify({ success: true, message: `shutdown_approved:${input.request_id}` })
}
sendPlainInboxMessage(
input.team_name,
sender,
"team-lead",
input.content ?? "Shutdown rejected",
"shutdown_rejected",
)
return JSON.stringify({ success: true, message: `shutdown_rejected:${input.request_id}` })
}
if (!input.recipient) {
return JSON.stringify({ error: "plan_response_requires_recipient" })
}
if (!memberNames.has(input.recipient)) {
return JSON.stringify({ error: "plan_response_recipient_not_found" })
}
if (input.approve) {
sendStructuredInboxMessage(
input.team_name,
sender,
input.recipient,
{
type: "plan_approval",
approved: true,
requestId: input.request_id,
},
"plan_approved",
)
return JSON.stringify({ success: true, message: `plan_approved:${input.recipient}` })
}
sendPlainInboxMessage(
input.team_name,
sender,
input.recipient,
input.content ?? "Plan rejected",
"plan_rejected",
)
return JSON.stringify({ success: true, message: `plan_rejected:${input.recipient}` })
} catch (error) {
return JSON.stringify({ error: error instanceof Error ? error.message : "send_message_failed" })
}
},
})
}
export function createReadInboxTool(): ToolDefinition {
return tool({
description: "Read inbox messages for a team member.",
args: {
team_name: tool.schema.string().describe("Team name"),
agent_name: tool.schema.string().describe("Member name"),
unread_only: tool.schema.boolean().optional().describe("Return only unread messages"),
mark_as_read: tool.schema.boolean().optional().describe("Mark returned messages as read"),
},
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
try {
const input = TeamReadInboxInputSchema.parse(args)
const teamError = validateTeamName(input.team_name)
if (teamError) {
return JSON.stringify({ error: teamError })
}
const agentError = validateAgentNameOrLead(input.agent_name)
if (agentError) {
return JSON.stringify({ error: agentError })
}
const config = readTeamConfigOrThrow(input.team_name)
const actor = resolveSenderFromContext(config, context)
if (!actor) {
return JSON.stringify({ error: "unauthorized_reader_session" })
}
if (actor !== "team-lead" && actor !== input.agent_name) {
return JSON.stringify({ error: "unauthorized_reader_session" })
}
const messages = readInbox(
input.team_name,
input.agent_name,
input.unread_only ?? false,
input.mark_as_read ?? true,
)
return JSON.stringify(messages)
} catch (error) {
return JSON.stringify({ error: error instanceof Error ? error.message : "read_inbox_failed" })
}
},
})
}

View File

@@ -1,79 +0,0 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import {
validateAgentName,
validateAgentNameOrLead,
validateTaskId,
validateTeamName,
} from "./name-validation"
describe("agent-teams name validation", () => {
test("accepts valid team names", () => {
//#given
const validNames = ["team_1", "alpha-team", "A1"]
//#when
const result = validNames.map(validateTeamName)
//#then
expect(result).toEqual([null, null, null])
})
test("rejects invalid and empty team names", () => {
//#given
const blank = ""
const invalid = "team space"
const tooLong = "a".repeat(65)
//#when
const blankResult = validateTeamName(blank)
const invalidResult = validateTeamName(invalid)
const tooLongResult = validateTeamName(tooLong)
//#then
expect(blankResult).toBe("team_name_required")
expect(invalidResult).toBe("team_name_invalid")
expect(tooLongResult).toBe("team_name_too_long")
})
test("rejects reserved teammate name", () => {
//#given
const reservedName = "team-lead"
//#when
const result = validateAgentName(reservedName)
//#then
expect(result).toBe("agent_name_reserved")
})
test("validates regular agent names", () => {
//#given
const valid = "worker_1"
const invalid = "worker one"
//#when
const validResult = validateAgentName(valid)
const invalidResult = validateAgentName(invalid)
//#then
expect(validResult).toBeNull()
expect(invalidResult).toBe("agent_name_invalid")
})
test("allows team-lead for inbox-compatible validation", () => {
//#then
expect(validateAgentNameOrLead("team-lead")).toBeNull()
expect(validateAgentNameOrLead("worker_1")).toBeNull()
expect(validateAgentNameOrLead("worker one")).toBe("agent_name_invalid")
})
test("validates task ids", () => {
//#then
expect(validateTaskId("T-123")).toBeNull()
expect(validateTaskId("123")).toBe("task_id_invalid")
expect(validateTaskId("")).toBe("task_id_required")
expect(validateTaskId("../../etc/passwd")).toBe("task_id_invalid")
expect(validateTaskId(`T-${"a".repeat(127)}`)).toBe("task_id_too_long")
})
})

View File

@@ -1,54 +0,0 @@
const VALID_NAME_RE = /^[A-Za-z0-9_-]+$/
const MAX_NAME_LENGTH = 64
const VALID_TASK_ID_RE = /^T-[A-Za-z0-9_-]+$/
const MAX_TASK_ID_LENGTH = 128
function validateName(value: string, label: "team" | "agent"): string | null {
if (!value || !value.trim()) {
return `${label}_name_required`
}
if (!VALID_NAME_RE.test(value)) {
return `${label}_name_invalid`
}
if (value.length > MAX_NAME_LENGTH) {
return `${label}_name_too_long`
}
return null
}
export function validateTeamName(teamName: string): string | null {
return validateName(teamName, "team")
}
export function validateAgentName(agentName: string): string | null {
if (agentName === "team-lead") {
return "agent_name_reserved"
}
return validateName(agentName, "agent")
}
export function validateAgentNameOrLead(agentName: string): string | null {
if (agentName === "team-lead") {
return null
}
return validateName(agentName, "agent")
}
export function validateTaskId(taskId: string): string | null {
if (!taskId || !taskId.trim()) {
return "task_id_required"
}
if (!VALID_TASK_ID_RE.test(taskId)) {
return "task_id_invalid"
}
if (taskId.length > MAX_TASK_ID_LENGTH) {
return "task_id_too_long"
}
return null
}

View File

@@ -1,81 +0,0 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import { homedir } from "node:os"
import { join } from "node:path"
import {
getAgentTeamsRootDir,
getTeamConfigPath,
getTeamDir,
getTeamInboxDir,
getTeamInboxPath,
getTeamTaskDir,
getTeamTaskPath,
getTeamsRootDir,
getTeamTasksRootDir,
} from "./paths"
describe("agent-teams paths", () => {
test("uses user-global .sisyphus directory as storage root", () => {
//#given
const expectedRoot = join(homedir(), ".sisyphus", "agent-teams")
//#when
const root = getAgentTeamsRootDir()
//#then
expect(root).toBe(expectedRoot)
})
test("builds expected teams and tasks root directories", () => {
//#given
const expectedRoot = join(homedir(), ".sisyphus", "agent-teams")
//#when
const teamsRoot = getTeamsRootDir()
const tasksRoot = getTeamTasksRootDir()
//#then
expect(teamsRoot).toBe(join(expectedRoot, "teams"))
expect(tasksRoot).toBe(join(expectedRoot, "tasks"))
})
test("builds team-scoped config, inbox, and task file paths", () => {
//#given
const teamName = "alpha_team"
const agentName = "worker_1"
const taskId = "T-123"
const expectedTeamDir = join(getTeamsRootDir(), "alpha_team")
//#when
const teamDir = getTeamDir(teamName)
const configPath = getTeamConfigPath(teamName)
const inboxDir = getTeamInboxDir(teamName)
const inboxPath = getTeamInboxPath(teamName, agentName)
const taskDir = getTeamTaskDir(teamName)
const taskPath = getTeamTaskPath(teamName, taskId)
//#then
expect(teamDir).toBe(expectedTeamDir)
expect(configPath).toBe(join(expectedTeamDir, "config.json"))
expect(inboxDir).toBe(join(expectedTeamDir, "inboxes"))
expect(inboxPath).toBe(join(expectedTeamDir, "inboxes", `${agentName}.json`))
expect(taskDir).toBe(join(getTeamTasksRootDir(), "alpha_team"))
expect(taskPath).toBe(join(getTeamTasksRootDir(), "alpha_team", `${taskId}.json`))
})
test("sanitizes team names with invalid characters", () => {
//#given
const invalidTeamName = "team space/with@special#chars"
const expectedSanitized = "team-space-with-special-chars"
//#when
const teamDir = getTeamDir(invalidTeamName)
const configPath = getTeamConfigPath(invalidTeamName)
const taskDir = getTeamTaskDir(invalidTeamName)
//#then
expect(teamDir).toBe(join(getTeamsRootDir(), expectedSanitized))
expect(configPath).toBe(join(getTeamsRootDir(), expectedSanitized, "config.json"))
expect(taskDir).toBe(join(getTeamTasksRootDir(), expectedSanitized))
})
})

View File

@@ -1,42 +0,0 @@
import { join } from "node:path"
import { homedir } from "node:os"
import { sanitizePathSegment } from "../../features/claude-tasks/storage"
const SISYPHUS_DIR = ".sisyphus"
const AGENT_TEAMS_DIR = "agent-teams"
export function getAgentTeamsRootDir(): string {
return join(homedir(), SISYPHUS_DIR, AGENT_TEAMS_DIR)
}
export function getTeamsRootDir(): string {
return join(getAgentTeamsRootDir(), "teams")
}
export function getTeamTasksRootDir(): string {
return join(getAgentTeamsRootDir(), "tasks")
}
export function getTeamDir(teamName: string): string {
return join(getTeamsRootDir(), sanitizePathSegment(teamName))
}
export function getTeamConfigPath(teamName: string): string {
return join(getTeamDir(teamName), "config.json")
}
export function getTeamInboxDir(teamName: string): string {
return join(getTeamDir(teamName), "inboxes")
}
export function getTeamInboxPath(teamName: string, agentName: string): string {
return join(getTeamInboxDir(teamName), `${agentName}.json`)
}
export function getTeamTaskDir(teamName: string): string {
return join(getTeamTasksRootDir(), sanitizePathSegment(teamName))
}
export function getTeamTaskPath(teamName: string, taskId: string): string {
return join(getTeamTaskDir(teamName), `${taskId}.json`)
}

View File

@@ -1,214 +0,0 @@
/// <reference types="bun-types" />
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"
import { chmodSync, existsSync, mkdtempSync, rmSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { randomUUID } from "node:crypto"
import { acquireLock } from "../../features/claude-tasks/storage"
import { getTeamDir, getTeamTaskDir, getTeamsRootDir } from "./paths"
import {
createTeamConfig,
deleteTeamData,
deleteTeamDir,
listTeams,
readTeamConfigOrThrow,
teamExists,
upsertTeammate,
writeTeamConfig,
} from "./team-config-store"
describe("agent-teams team config store", () => {
let originalCwd: string
let tempProjectDir: string
let createdTeams: string[]
let teamPrefix: string
beforeAll(() => {
const allTeams = listTeams()
for (const team of allTeams) {
if (team.startsWith("core-") || team.startsWith("team-alpha-") || team.startsWith("team-beta-") || team.startsWith("delete-dir-test-")) {
try {
deleteTeamData(team)
} catch {
// Ignore cleanup errors
}
}
}
})
beforeEach(() => {
originalCwd = process.cwd()
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-config-store-"))
process.chdir(tempProjectDir)
createdTeams = []
teamPrefix = randomUUID().slice(0, 8)
createTeamConfig(`core-${teamPrefix}`, "Core team", `ses-main-${teamPrefix}`, tempProjectDir, "sisyphus")
createdTeams.push(`core-${teamPrefix}`)
})
afterAll(() => {
for (const teamName of createdTeams) {
if (teamExists(teamName)) {
try {
deleteTeamData(teamName)
} catch {
// Ignore cleanup errors
}
}
}
process.chdir(originalCwd)
try {
rmSync(tempProjectDir, { recursive: true, force: true })
} catch {
// Ignore cleanup errors
}
})
test("deleteTeamData waits for team lock before removing team files", () => {
//#given
const teamName = createdTeams[0]
const lock = acquireLock(getTeamDir(teamName))
expect(lock.acquired).toBe(true)
try {
//#when
const deleteWhileLocked = () => deleteTeamData(teamName)
//#then
expect(deleteWhileLocked).toThrow("team_lock_unavailable")
expect(teamExists(teamName)).toBe(true)
} finally {
//#when
lock.release()
}
deleteTeamData(teamName)
//#then
expect(teamExists(teamName)).toBe(false)
})
test("deleteTeamData waits for task lock before removing task files", () => {
//#given
const teamName = createdTeams[0]
const lock = acquireLock(getTeamTaskDir(teamName))
expect(lock.acquired).toBe(true)
try {
//#when
const deleteWhileLocked = () => deleteTeamData(teamName)
//#then
expect(deleteWhileLocked).toThrow("team_task_lock_unavailable")
expect(teamExists(teamName)).toBe(true)
} finally {
lock.release()
}
//#when
deleteTeamData(teamName)
//#then
expect(teamExists(teamName)).toBe(false)
})
test("deleteTeamData removes task files before deleting team directory", () => {
//#given
const teamName = createdTeams[0]
const taskDir = getTeamTaskDir(teamName)
const teamDir = getTeamDir(teamName)
const teamsRootDir = getTeamsRootDir()
expect(existsSync(taskDir)).toBe(true)
expect(existsSync(teamDir)).toBe(true)
//#when
chmodSync(teamsRootDir, 0o555)
try {
const deleteWithBlockedTeamParent = () => deleteTeamData(teamName)
expect(deleteWithBlockedTeamParent).toThrow()
} finally {
chmodSync(teamsRootDir, 0o755)
}
//#then
expect(existsSync(taskDir)).toBe(false)
expect(existsSync(teamDir)).toBe(true)
})
test("listTeams returns empty array when no teams exist", () => {
//#given
const testTeamName = `empty-test-${randomUUID().slice(0, 8)}`
const allTeamsBefore = listTeams().filter(t => !t.startsWith("core-") && !t.startsWith("team-alpha-") && !t.startsWith("team-beta-") && !t.startsWith("delete-dir-test-"))
const uniqueTestTeam = allTeamsBefore.find(t => t !== testTeamName)
//#when
const teams = listTeams()
//#then
expect(teams.length).toBeGreaterThanOrEqual(allTeamsBefore.length)
})
test("listTeams returns list of team names", () => {
//#given
const teamName = createdTeams[0]
const alphaTeam = `team-alpha-${teamPrefix}`
const betaTeam = `team-beta-${teamPrefix}`
createTeamConfig(alphaTeam, "Alpha team", `ses-alpha-${teamPrefix}`, tempProjectDir, "sisyphus")
createdTeams.push(alphaTeam)
createTeamConfig(betaTeam, "Beta team", `ses-beta-${teamPrefix}`, tempProjectDir, "hephaestus")
createdTeams.push(betaTeam)
//#when
const teams = listTeams()
//#then
expect(teams).toContain(teamName)
expect(teams).toContain(alphaTeam)
expect(teams).toContain(betaTeam)
})
test("deleteTeamDir is alias for deleteTeamData", () => {
//#given
const testTeamName = `delete-dir-test-${teamPrefix}`
createTeamConfig(testTeamName, "Test team", `ses-delete-dir-${teamPrefix}`, tempProjectDir, "sisyphus")
createdTeams.push(testTeamName)
expect(teamExists(testTeamName)).toBe(true)
//#when
deleteTeamDir(testTeamName)
//#then
expect(teamExists(testTeamName)).toBe(false)
})
test("deleteTeamData fails if team has active teammates", () => {
//#given
const teamName = createdTeams[0]
const config = readTeamConfigOrThrow(teamName)
const updated = upsertTeammate(config, {
agentId: `teammate@${teamName}`,
name: "teammate",
agentType: "teammate",
category: "test",
model: "sisyphus",
prompt: "test prompt",
color: "#000000",
planModeRequired: false,
joinedAt: new Date().toISOString(),
cwd: process.cwd(),
subscriptions: [],
backendType: "native",
isActive: true,
sessionID: "ses-sub",
})
writeTeamConfig(teamName, updated)
//#when
const deleteWithTeammates = () => deleteTeamData(teamName)
//#then
expect(deleteWithTeammates).toThrow("team_has_active_members")
expect(teamExists(teamName)).toBe(true)
})
})

View File

@@ -1,217 +0,0 @@
import { existsSync, readdirSync, rmSync } from "node:fs"
import { acquireLock, ensureDir, readJsonSafe, writeJsonAtomic } from "../../features/claude-tasks/storage"
import {
getTeamConfigPath,
getTeamDir,
getTeamInboxDir,
getTeamTaskDir,
getTeamTasksRootDir,
getTeamsRootDir,
} from "./paths"
import {
TEAM_COLOR_PALETTE,
TeamConfig,
TeamConfigSchema,
TeamLeadMember,
TeamMember,
TeamTeammateMember,
isTeammateMember,
} from "./types"
import { validateTeamName } from "./name-validation"
import { withTeamTaskLock } from "./team-task-store"
function nowMs(): string {
return new Date().toISOString()
}
function assertValidTeamName(teamName: string): void {
const validationError = validateTeamName(teamName)
if (validationError) {
throw new Error(validationError)
}
}
function withTeamLock<T>(teamName: string, operation: () => T): T {
assertValidTeamName(teamName)
const teamDir = getTeamDir(teamName)
ensureDir(teamDir)
const lock = acquireLock(teamDir)
if (!lock.acquired) {
throw new Error("team_lock_unavailable")
}
try {
return operation()
} finally {
lock.release()
}
}
function createLeadMember(teamName: string, cwd: string, leadModel: string): TeamLeadMember {
return {
agentId: `team-lead@${teamName}`,
name: "team-lead",
agentType: "team-lead",
color: "#2D3748",
model: leadModel,
joinedAt: nowMs(),
cwd,
subscriptions: [],
}
}
export function ensureTeamStorageDirs(teamName: string): void {
assertValidTeamName(teamName)
ensureDir(getTeamsRootDir())
ensureDir(getTeamTasksRootDir())
ensureDir(getTeamDir(teamName))
ensureDir(getTeamInboxDir(teamName))
ensureDir(getTeamTaskDir(teamName))
}
export function teamExists(teamName: string): boolean {
assertValidTeamName(teamName)
return existsSync(getTeamConfigPath(teamName))
}
export function createTeamConfig(
teamName: string,
description: string,
leadSessionId: string,
cwd: string,
leadModel: string,
): TeamConfig {
ensureTeamStorageDirs(teamName)
const leadAgentId = `team-lead@${teamName}`
const config: TeamConfig = {
name: teamName,
description,
createdAt: nowMs(),
leadAgentId,
leadSessionId,
members: [createLeadMember(teamName, cwd, leadModel)],
}
return withTeamLock(teamName, () => {
if (teamExists(teamName)) {
throw new Error("team_already_exists")
}
writeJsonAtomic(getTeamConfigPath(teamName), TeamConfigSchema.parse(config))
return config
})
}
export function readTeamConfig(teamName: string): TeamConfig | null {
assertValidTeamName(teamName)
return readJsonSafe(getTeamConfigPath(teamName), TeamConfigSchema)
}
export function readTeamConfigOrThrow(teamName: string): TeamConfig {
const config = readTeamConfig(teamName)
if (!config) {
throw new Error("team_not_found")
}
return config
}
export function writeTeamConfig(teamName: string, config: TeamConfig): TeamConfig {
assertValidTeamName(teamName)
return withTeamLock(teamName, () => {
const validated = TeamConfigSchema.parse(config)
writeJsonAtomic(getTeamConfigPath(teamName), validated)
return validated
})
}
export function updateTeamConfig(teamName: string, updater: (config: TeamConfig) => TeamConfig): TeamConfig {
assertValidTeamName(teamName)
return withTeamLock(teamName, () => {
const current = readJsonSafe(getTeamConfigPath(teamName), TeamConfigSchema)
if (!current) {
throw new Error("team_not_found")
}
const next = TeamConfigSchema.parse(updater(current))
writeJsonAtomic(getTeamConfigPath(teamName), next)
return next
})
}
export function listTeammates(config: TeamConfig): TeamTeammateMember[] {
return config.members.filter(isTeammateMember)
}
export function getTeamMember(config: TeamConfig, name: string): TeamMember | undefined {
return config.members.find((member) => member.name === name)
}
export function upsertTeammate(config: TeamConfig, teammate: TeamTeammateMember): TeamConfig {
const members = config.members.filter((member) => member.name !== teammate.name)
members.push(teammate)
return { ...config, members }
}
export function removeTeammate(config: TeamConfig, agentName: string): TeamConfig {
if (agentName === "team-lead") {
throw new Error("cannot_remove_team_lead")
}
return {
...config,
members: config.members.filter((member) => member.name !== agentName),
}
}
export function assignNextColor(config: TeamConfig): string {
const teammateCount = listTeammates(config).length
return TEAM_COLOR_PALETTE[teammateCount % TEAM_COLOR_PALETTE.length]
}
export function deleteTeamData(teamName: string): void {
assertValidTeamName(teamName)
withTeamLock(teamName, () => {
const config = readJsonSafe(getTeamConfigPath(teamName), TeamConfigSchema)
if (!config) {
throw new Error("team_not_found")
}
if (listTeammates(config).length > 0) {
throw new Error("team_has_active_members")
}
withTeamTaskLock(teamName, () => {
const teamDir = getTeamDir(teamName)
const taskDir = getTeamTaskDir(teamName)
if (existsSync(taskDir)) {
rmSync(taskDir, { recursive: true, force: true })
}
if (existsSync(teamDir)) {
rmSync(teamDir, { recursive: true, force: true })
}
})
})
}
export function deleteTeamDir(teamName: string): void {
deleteTeamData(teamName)
}
export function listTeams(): string[] {
const teamsRootDir = getTeamsRootDir()
if (!existsSync(teamsRootDir)) {
return []
}
try {
const entries = readdirSync(teamsRootDir, { withFileTypes: true })
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.filter((name) => existsSync(getTeamConfigPath(name)))
} catch {
return []
}
}

View File

@@ -1,297 +0,0 @@
/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { existsSync, mkdtempSync, rmSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { randomUUID } from "node:crypto"
import { createTeamCreateTool, createTeamDeleteTool } from "./team-lifecycle-tools"
import { getTeamConfigPath, getTeamDir, getTeamTaskDir } from "./paths"
import { readTeamConfig, listTeammates } from "./team-config-store"
import { getTeamsRootDir, getTeamTasksRootDir } from "./paths"
import { deleteTeamData } from "./team-config-store"
const TEST_SUFFIX = randomUUID().substring(0, 8)
interface TestToolContext {
sessionID: string
messageID: string
agent: string
abort: AbortSignal
}
function createContext(sessionID = "ses-main"): TestToolContext {
return {
sessionID,
messageID: "msg-main",
agent: "sisyphus",
abort: new AbortController().signal as AbortSignal,
}
}
async function executeJsonTool(
tool: ReturnType<typeof createTeamCreateTool | typeof createTeamDeleteTool>,
args: Record<string, unknown>,
context: TestToolContext,
): Promise<unknown> {
const output = await tool.execute(args, context)
return JSON.parse(output)
}
describe("team_lifecycle tools", () => {
let originalCwd: string
let tempProjectDir: string
beforeEach(() => {
originalCwd = process.cwd()
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-lifecycle-"))
process.chdir(tempProjectDir)
})
afterEach(() => {
process.chdir(originalCwd)
rmSync(tempProjectDir, { recursive: true, force: true })
})
describe("team_create", () => {
test("creates team with valid name and description", async () => {
//#given
const tool = createTeamCreateTool()
const context = createContext()
const teamName = `test-team-${TEST_SUFFIX}`
//#when
const result = await executeJsonTool(tool, {
team_name: teamName,
description: "My test team",
}, context)
//#then
expect(result).toEqual({
team_name: teamName,
config_path: getTeamConfigPath(teamName),
lead_agent_id: `team-lead@${teamName}`,
})
// Verify team was actually created
const teamConfig = readTeamConfig(teamName)
expect(teamConfig).not.toBeNull()
expect(teamConfig?.name).toBe(teamName)
expect(teamConfig?.description).toBe("My test team")
expect(teamConfig?.leadAgentId).toBe(`team-lead@${teamName}`)
expect(teamConfig?.leadSessionId).toBe("ses-main")
expect(teamConfig?.members).toHaveLength(1)
expect(teamConfig?.members[0].agentType).toBe("team-lead")
})
test("creates team with only name (description optional)", async () => {
//#given
const tool = createTeamCreateTool()
const context = createContext()
const teamName = `minimal-team-${TEST_SUFFIX}`
//#when
const result = await executeJsonTool(tool, {
team_name: teamName,
}, context)
//#then
expect(result).toEqual({
team_name: teamName,
config_path: getTeamConfigPath(teamName),
lead_agent_id: `team-lead@${teamName}`,
})
const teamConfig = readTeamConfig(teamName)
expect(teamConfig?.description).toBe("")
})
test("validates team name format (alphanumeric, hyphens, underscores only)", async () => {
//#given
const tool = createTeamCreateTool()
const context = createContext()
//#when
const result = await executeJsonTool(tool, {
team_name: "invalid@name",
}, context)
//#then
expect(result).toEqual({
error: "team_create_failed",
})
})
test("validates team name max length (64 chars)", async () => {
//#given
const tool = createTeamCreateTool()
const context = createContext()
const longName = "a".repeat(65)
//#when
const result = await executeJsonTool(tool, {
team_name: longName,
}, context)
//#then
expect(result).toEqual({
error: "team_create_failed",
})
})
test("rejects duplicate team names", async () => {
//#given
const tool = createTeamCreateTool()
const context1 = createContext("ses-1")
const context2 = createContext("ses-2")
const teamName = `duplicate-team-${TEST_SUFFIX}`
// Create team first
await executeJsonTool(tool, {
team_name: teamName,
}, context1)
//#when - try to create same team again
const result = await executeJsonTool(tool, {
team_name: teamName,
}, context2)
//#then
expect(result).toEqual({
error: "team_already_exists",
})
// Verify first team still exists
const teamConfig = readTeamConfig(teamName)
expect(teamConfig).not.toBeNull()
})
})
describe("team_delete", () => {
test("deletes team when no active teammates", async () => {
//#given
const createTool = createTeamCreateTool()
const deleteTool = createTeamDeleteTool()
const context = createContext()
const teamName = `test-delete-team-${TEST_SUFFIX}`
// Create team first
await executeJsonTool(createTool, {
team_name: teamName,
}, context)
//#when
const result = await executeJsonTool(deleteTool, {
team_name: teamName,
}, context)
//#then
expect(result).toEqual({
deleted: true,
team_name: teamName,
})
// Verify team dir is deleted
expect(existsSync(getTeamDir(teamName))).toBe(false)
expect(existsSync(getTeamTaskDir(teamName))).toBe(false)
expect(existsSync(getTeamConfigPath(teamName))).toBe(false)
})
test("blocks deletion when team has active teammates", async () => {
//#given
const createTool = createTeamCreateTool()
const deleteTool = createTeamDeleteTool()
const context = createContext()
const teamName = `team-with-members-${TEST_SUFFIX}`
// Create team
await executeJsonTool(createTool, {
team_name: teamName,
}, context)
// Add a teammate by modifying config directly for test
const teamConfig = readTeamConfig(teamName)
expect(teamConfig).not.toBeNull()
// Manually add a teammate to simulate active member
const { writeTeamConfig } = await import("./team-config-store")
if (teamConfig) {
writeTeamConfig(teamName, {
...teamConfig,
members: [
...teamConfig.members,
{
agentId: "teammate-1",
name: "test-teammate",
agentType: "teammate",
color: "#FF6B6B",
category: "test",
model: "test-model",
prompt: "Test prompt",
planModeRequired: false,
joinedAt: new Date().toISOString(),
cwd: "/tmp",
subscriptions: [],
backendType: "native",
isActive: true,
sessionID: "test-session",
},
],
})
}
//#when
const result = await executeJsonTool(deleteTool, {
team_name: teamName,
}, context)
//#then
expect(result).toEqual({
error: "team_has_active_members",
members: ["test-teammate"],
})
// Cleanup - manually remove teammates first, then delete
const configApi = await import("./team-config-store")
const cleanupConfig = readTeamConfig(teamName)
if (cleanupConfig) {
configApi.writeTeamConfig(teamName, {
...cleanupConfig,
members: cleanupConfig.members.filter((m) => m.agentType === "team-lead"),
})
configApi.deleteTeamData(teamName)
}
})
test("validates team name format on deletion", async () => {
//#given
const deleteTool = createTeamDeleteTool()
const context = createContext()
const teamName = `invalid-team-${TEST_SUFFIX}`
//#when
const result = await executeJsonTool(deleteTool, {
team_name: "invalid@name",
}, context)
//#then - Zod returns detailed validation error array
const parsedResult = result as { error: string }
expect(parsedResult.error).toContain("Team name must contain only letters")
})
test("returns error for non-existent team", async () => {
//#given
const deleteTool = createTeamDeleteTool()
const context = createContext()
//#when
const result = await executeJsonTool(deleteTool, {
team_name: "non-existent-team",
}, context)
//#then
expect(result).toEqual({
error: "team_not_found",
})
})
})
})

View File

@@ -1,151 +0,0 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { z } from "zod"
import { getTeamConfigPath } from "./paths"
import { validateTeamName } from "./name-validation"
import { ensureInbox } from "./inbox-store"
import {
TeamConfig,
TeamCreateInputSchema,
TeamDeleteInputSchema,
TeamReadConfigInputSchema,
TeamToolContext,
isTeammateMember,
} from "./types"
import { createTeamConfig, deleteTeamData, listTeammates, readTeamConfig, readTeamConfigOrThrow } from "./team-config-store"
function resolveReaderFromContext(config: TeamConfig, context: TeamToolContext): "team-lead" | string | null {
if (context.sessionID === config.leadSessionId) {
return "team-lead"
}
const matchedMember = config.members.find((member) => isTeammateMember(member) && member.sessionID === context.sessionID)
return matchedMember?.name ?? null
}
function toPublicTeamConfig(config: TeamConfig): {
team_name: string
description: string | undefined
lead_agent_id: string
teammates: Array<{ name: string }>
} {
return {
team_name: config.name,
description: config.description,
lead_agent_id: config.leadAgentId,
teammates: listTeammates(config).map((member) => ({ name: member.name })),
}
}
export function createTeamCreateTool(): ToolDefinition {
return tool({
description: "Create a team workspace with config, inboxes, and task storage.",
args: {
team_name: tool.schema.string().describe("Team name (letters, numbers, hyphens, underscores)"),
description: tool.schema.string().optional().describe("Team description"),
},
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
try {
const input = TeamCreateInputSchema.parse(args)
const config = createTeamConfig(
input.team_name,
input.description ?? "",
context.sessionID,
process.cwd(),
"native/team-lead",
)
ensureInbox(config.name, "team-lead")
return JSON.stringify({
team_name: config.name,
config_path: getTeamConfigPath(config.name) as string,
lead_agent_id: config.leadAgentId,
})
} catch (error) {
if (error instanceof Error && error.message === "team_already_exists") {
return JSON.stringify({ error: error.message })
}
return JSON.stringify({ error: "team_create_failed" })
}
},
})
}
export function createTeamDeleteTool(): ToolDefinition {
return tool({
description: "Delete a team and its stored data. Fails if teammates still exist.",
args: {
team_name: tool.schema.string().describe("Team name"),
},
execute: async (args: Record<string, unknown>, _context: TeamToolContext): Promise<string> => {
let teamName: string | undefined
try {
const input = TeamDeleteInputSchema.parse(args)
teamName = input.team_name
const config = readTeamConfig(input.team_name)
if (!config) {
return JSON.stringify({ error: "team_not_found" })
}
const teammates = listTeammates(config)
if (teammates.length > 0) {
return JSON.stringify({
error: "team_has_active_members",
members: teammates.map((member) => member.name),
})
}
deleteTeamData(input.team_name)
return JSON.stringify({ deleted: true, team_name: input.team_name })
} catch (error) {
if (error instanceof Error) {
if (error.message === "team_has_active_members") {
const config = readTeamConfig(teamName!)
const activeMembers = config ? listTeammates(config) : []
return JSON.stringify({
error: "team_has_active_members",
members: activeMembers.map((member) => member.name),
})
}
if (error.message === "team_not_found") {
return JSON.stringify({ error: "team_not_found" })
}
return JSON.stringify({ error: error.message })
}
return JSON.stringify({ error: "team_delete_failed" })
}
},
})
}
export function createTeamReadConfigTool(): ToolDefinition {
return tool({
description: "Read team configuration and member list.",
args: {
team_name: tool.schema.string().describe("Team name"),
},
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
try {
const input = TeamReadConfigInputSchema.parse(args)
const config = readTeamConfig(input.team_name)
if (!config) {
return JSON.stringify({ error: "team_not_found" })
}
const actor = resolveReaderFromContext(config, context)
if (!actor) {
return JSON.stringify({ error: "unauthorized_reader_session" })
}
if (actor !== "team-lead") {
return JSON.stringify(toPublicTeamConfig(config))
}
return JSON.stringify(config)
} catch (error) {
return JSON.stringify({ error: error instanceof Error ? error.message : "team_read_config_failed" })
}
},
})
}

View File

@@ -1,94 +0,0 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import {
addPendingEdge,
createPendingEdgeMap,
ensureDependenciesCompleted,
ensureForwardStatusTransition,
wouldCreateCycle,
} from "./team-task-dependency"
import type { TeamTask, TeamTaskStatus } from "./types"
function createTask(id: string, status: TeamTaskStatus, blockedBy: string[] = []): TeamTask {
return {
id,
subject: `Task ${id}`,
description: `Description ${id}`,
status,
blocks: [],
blockedBy,
}
}
describe("agent-teams task dependency utilities", () => {
test("detects cycle from existing blockedBy chain", () => {
//#given
const tasks = new Map<string, TeamTask>([
["A", createTask("A", "pending", ["B"])],
["B", createTask("B", "pending")],
])
const pending = createPendingEdgeMap()
const readTask = (id: string) => tasks.get(id) ?? null
//#when
const hasCycle = wouldCreateCycle("B", "A", pending, readTask)
//#then
expect(hasCycle).toBe(true)
})
test("detects cycle from pending edge map", () => {
//#given
const tasks = new Map<string, TeamTask>([["A", createTask("A", "pending")]])
const pending = createPendingEdgeMap()
addPendingEdge(pending, "A", "B")
const readTask = (id: string) => tasks.get(id) ?? null
//#when
const hasCycle = wouldCreateCycle("B", "A", pending, readTask)
//#then
expect(hasCycle).toBe(true)
})
test("returns false when dependency graph has no cycle", () => {
//#given
const tasks = new Map<string, TeamTask>([
["A", createTask("A", "pending")],
["B", createTask("B", "pending", ["A"])],
])
const pending = createPendingEdgeMap()
const readTask = (id: string) => tasks.get(id) ?? null
//#when
const hasCycle = wouldCreateCycle("C", "B", pending, readTask)
//#then
expect(hasCycle).toBe(false)
})
test("allows forward status transitions and blocks backward transitions", () => {
//#then
expect(() => ensureForwardStatusTransition("pending", "in_progress")).not.toThrow()
expect(() => ensureForwardStatusTransition("in_progress", "completed")).not.toThrow()
expect(() => ensureForwardStatusTransition("in_progress", "pending")).toThrow(
"invalid_status_transition:in_progress->pending",
)
})
test("requires blockers to be completed for in_progress/completed", () => {
//#given
const tasks = new Map<string, TeamTask>([
["done", createTask("done", "completed")],
["wait", createTask("wait", "pending")],
])
const readTask = (id: string) => tasks.get(id) ?? null
//#then
expect(() => ensureDependenciesCompleted("pending", ["wait"], readTask)).not.toThrow()
expect(() => ensureDependenciesCompleted("in_progress", ["done"], readTask)).not.toThrow()
expect(() => ensureDependenciesCompleted("completed", ["wait"], readTask)).toThrow(
"blocked_by_incomplete:wait:pending",
)
})
})

View File

@@ -1,93 +0,0 @@
import type { TeamTask, TeamTaskStatus } from "./types"
type PendingEdges = Record<string, Set<string>>
export const TEAM_TASK_STATUS_ORDER: Record<TeamTaskStatus, number> = {
pending: 0,
in_progress: 1,
completed: 2,
deleted: 3,
}
export type TaskReader = (taskId: string) => TeamTask | null
export function wouldCreateCycle(
fromTaskId: string,
toTaskId: string,
pendingEdges: PendingEdges,
readTask: TaskReader,
): boolean {
const visited = new Set<string>()
const queue: string[] = [toTaskId]
while (queue.length > 0) {
const current = queue.shift()
if (!current) {
continue
}
if (current === fromTaskId) {
return true
}
if (visited.has(current)) {
continue
}
visited.add(current)
const task = readTask(current)
if (task) {
for (const dep of task.blockedBy) {
if (!visited.has(dep)) {
queue.push(dep)
}
}
}
const pending = pendingEdges[current]
if (pending) {
for (const dep of pending) {
if (!visited.has(dep)) {
queue.push(dep)
}
}
}
}
return false
}
export function ensureForwardStatusTransition(current: TeamTaskStatus, next: TeamTaskStatus): void {
const currentOrder = TEAM_TASK_STATUS_ORDER[current]
const nextOrder = TEAM_TASK_STATUS_ORDER[next]
if (nextOrder < currentOrder) {
throw new Error(`invalid_status_transition:${current}->${next}`)
}
}
export function ensureDependenciesCompleted(
status: TeamTaskStatus,
blockedBy: string[],
readTask: TaskReader,
): void {
if (status !== "in_progress" && status !== "completed") {
return
}
for (const blockerId of blockedBy) {
const blocker = readTask(blockerId)
if (blocker && blocker.status !== "completed") {
throw new Error(`blocked_by_incomplete:${blockerId}:${blocker.status}`)
}
}
}
export function createPendingEdgeMap(): PendingEdges {
return {}
}
export function addPendingEdge(pendingEdges: PendingEdges, from: string, to: string): void {
const existing = pendingEdges[from] ?? new Set<string>()
existing.add(to)
pendingEdges[from] = existing
}

View File

@@ -1,460 +0,0 @@
/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { dirname } from "node:path"
import { ensureDir } from "../../features/claude-tasks/storage"
import { tmpdir } from "node:os"
import { join } from "node:path"
import {
getTeamTaskPath,
readTeamTask,
writeTeamTask,
listTeamTasks,
deleteTeamTask,
} from "./team-task-store"
import type { TeamTask } from "./types"
describe("getTeamTaskPath", () => {
test("returns correct file path for team task", () => {
//#given
const teamName = "my-team"
const taskId = "T-abc123"
//#when
const result = getTeamTaskPath(teamName, taskId)
//#then
expect(result).toContain("my-team")
expect(result).toContain("T-abc123.json")
})
})
describe("readTeamTask", () => {
let originalCwd: string
let tempProjectDir: string
beforeEach(() => {
originalCwd = process.cwd()
tempProjectDir = mkdtempSync(join(tmpdir(), "team-task-store-test-"))
process.chdir(tempProjectDir)
})
afterEach(() => {
process.chdir(originalCwd)
if (existsSync(tempProjectDir)) {
rmSync(tempProjectDir, { recursive: true, force: true })
}
})
test("returns null when task file does not exist", () => {
//#given
const teamName = "nonexistent-team"
const taskId = "T-does-not-exist"
//#when
const result = readTeamTask(teamName, taskId)
//#then
expect(result).toBeNull()
})
test("returns task when valid task file exists", () => {
//#given
const task: TeamTask = {
id: "T-existing-task",
subject: "Test task",
description: "Test description",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "ses_test",
}
writeTeamTask("test-team", "T-existing-task", task)
//#when
const result = readTeamTask("test-team", "T-existing-task")
//#then
expect(result).not.toBeNull()
expect(result?.id).toBe("T-existing-task")
expect(result?.subject).toBe("Test task")
})
test("returns null when task file contains invalid JSON", () => {
//#given
const taskPath = getTeamTaskPath("invalid-team", "T-invalid-json")
const parentDir = dirname(taskPath)
rmSync(parentDir, { recursive: true, force: true })
ensureDir(parentDir)
writeFileSync(taskPath, "{ invalid json }")
//#when
const result = readTeamTask("invalid-team", "T-invalid-json")
//#then
expect(result).toBeNull()
})
test("returns null when task file does not match schema", () => {
//#given
const taskPath = getTeamTaskPath("invalid-schema-team", "T-bad-schema")
const parentDir = dirname(taskPath)
rmSync(parentDir, { recursive: true, force: true })
ensureDir(parentDir)
const invalidData = { id: "T-bad-schema" }
writeFileSync(taskPath, JSON.stringify(invalidData))
//#when
const result = readTeamTask("invalid-schema-team", "T-bad-schema")
//#then
expect(result).toBeNull()
})
})
describe("writeTeamTask", () => {
let originalCwd: string
let tempProjectDir: string
beforeEach(() => {
originalCwd = process.cwd()
tempProjectDir = mkdtempSync(join(tmpdir(), "team-task-store-test-"))
process.chdir(tempProjectDir)
})
afterEach(() => {
process.chdir(originalCwd)
if (existsSync(tempProjectDir)) {
rmSync(tempProjectDir, { recursive: true, force: true })
}
})
test("creates task file in team namespace", () => {
//#given
const task: TeamTask = {
id: "T-write-test",
subject: "Write test task",
description: "Test writing task",
status: "in_progress",
blocks: [],
blockedBy: [],
threadID: "ses_write",
}
//#when
writeTeamTask("write-team", "T-write-test", task)
//#then
const taskPath = getTeamTaskPath("write-team", "T-write-test")
expect(existsSync(taskPath)).toBe(true)
})
test("overwrites existing task file", () => {
//#given
const task: TeamTask = {
id: "T-overwrite-test",
subject: "Original subject",
description: "Original description",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "ses_original",
}
writeTeamTask("overwrite-team", "T-overwrite-test", task)
const updatedTask: TeamTask = {
...task,
subject: "Updated subject",
status: "completed",
}
//#when
writeTeamTask("overwrite-team", "T-overwrite-test", updatedTask)
//#then
const result = readTeamTask("overwrite-team", "T-overwrite-test")
expect(result?.subject).toBe("Updated subject")
expect(result?.status).toBe("completed")
})
test("creates team directory if it does not exist", () => {
//#given
const task: TeamTask = {
id: "T-new-dir",
subject: "New directory test",
description: "Test",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "ses_newdir",
}
//#when
writeTeamTask("new-team-directory", "T-new-dir", task)
//#then
const taskPath = getTeamTaskPath("new-team-directory", "T-new-dir")
expect(existsSync(taskPath)).toBe(true)
})
})
describe("listTeamTasks", () => {
let originalCwd: string
let tempProjectDir: string
beforeEach(() => {
originalCwd = process.cwd()
tempProjectDir = mkdtempSync(join(tmpdir(), "team-task-store-test-"))
process.chdir(tempProjectDir)
})
afterEach(() => {
process.chdir(originalCwd)
if (existsSync(tempProjectDir)) {
rmSync(tempProjectDir, { recursive: true, force: true })
}
})
test("returns empty array when team has no tasks", () => {
//#given
// No tasks written
//#when
const result = listTeamTasks("empty-team")
//#then
expect(result).toEqual([])
})
test("returns all tasks for a team", () => {
//#given
const task1: TeamTask = {
id: "T-task-1",
subject: "Task 1",
description: "First task",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "ses_1",
}
const task2: TeamTask = {
id: "T-task-2",
subject: "Task 2",
description: "Second task",
status: "in_progress",
blocks: [],
blockedBy: [],
threadID: "ses_2",
}
writeTeamTask("list-test-team", "T-task-1", task1)
writeTeamTask("list-test-team", "T-task-2", task2)
//#when
const result = listTeamTasks("list-test-team")
//#then
expect(result).toHaveLength(2)
expect(result.some((t) => t.id === "T-task-1")).toBe(true)
expect(result.some((t) => t.id === "T-task-2")).toBe(true)
})
test("includes tasks with all statuses", () => {
//#given
const pendingTask: TeamTask = {
id: "T-pending",
subject: "Pending task",
description: "Test",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "ses_pending",
}
const inProgressTask: TeamTask = {
id: "T-in-progress",
subject: "In progress task",
description: "Test",
status: "in_progress",
blocks: [],
blockedBy: [],
threadID: "ses_inprogress",
}
const completedTask: TeamTask = {
id: "T-completed",
subject: "Completed task",
description: "Test",
status: "completed",
blocks: [],
blockedBy: [],
threadID: "ses_completed",
}
const deletedTask: TeamTask = {
id: "T-deleted",
subject: "Deleted task",
description: "Test",
status: "deleted",
blocks: [],
blockedBy: [],
threadID: "ses_deleted",
}
writeTeamTask("status-test-team", "T-pending", pendingTask)
writeTeamTask("status-test-team", "T-in-progress", inProgressTask)
writeTeamTask("status-test-team", "T-completed", completedTask)
writeTeamTask("status-test-team", "T-deleted", deletedTask)
//#when
const result = listTeamTasks("status-test-team")
//#then
expect(result).toHaveLength(4)
const statuses = result.map((t) => t.status)
expect(statuses).toContain("pending")
expect(statuses).toContain("in_progress")
expect(statuses).toContain("completed")
expect(statuses).toContain("deleted")
})
test("does not include tasks from other teams", () => {
//#given
const taskTeam1: TeamTask = {
id: "T-team1-task",
subject: "Team 1 task",
description: "Test",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "ses_team1",
}
const taskTeam2: TeamTask = {
id: "T-team2-task",
subject: "Team 2 task",
description: "Test",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "ses_team2",
}
writeTeamTask("team-1", "T-team1-task", taskTeam1)
writeTeamTask("team-2", "T-team2-task", taskTeam2)
//#when
const result = listTeamTasks("team-1")
//#then
expect(result).toHaveLength(1)
expect(result[0].id).toBe("T-team1-task")
})
})
describe("deleteTeamTask", () => {
let originalCwd: string
let tempProjectDir: string
beforeEach(() => {
originalCwd = process.cwd()
tempProjectDir = mkdtempSync(join(tmpdir(), "team-task-store-test-"))
process.chdir(tempProjectDir)
})
afterEach(() => {
process.chdir(originalCwd)
if (existsSync(tempProjectDir)) {
rmSync(tempProjectDir, { recursive: true, force: true })
}
})
test("deletes existing task file", () => {
//#given
const task: TeamTask = {
id: "T-delete-me",
subject: "Delete this task",
description: "Test",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "ses_delete",
}
writeTeamTask("delete-test-team", "T-delete-me", task)
const taskPath = getTeamTaskPath("delete-test-team", "T-delete-me")
//#when
deleteTeamTask("delete-test-team", "T-delete-me")
//#then
expect(existsSync(taskPath)).toBe(false)
})
test("does not throw when task does not exist", () => {
//#given
// Task does not exist
//#when
expect(() => deleteTeamTask("nonexistent-team", "T-does-not-exist")).not.toThrow()
//#then
// No exception thrown
})
test("does not affect other tasks in same team", () => {
//#given
const task1: TeamTask = {
id: "T-keep-me",
subject: "Keep this task",
description: "Test",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "ses_keep",
}
const task2: TeamTask = {
id: "T-delete-me",
subject: "Delete this task",
description: "Test",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "ses_delete",
}
writeTeamTask("mixed-test-team", "T-keep-me", task1)
writeTeamTask("mixed-test-team", "T-delete-me", task2)
//#when
deleteTeamTask("mixed-test-team", "T-delete-me")
//#then
const remaining = listTeamTasks("mixed-test-team")
expect(remaining).toHaveLength(1)
expect(remaining[0].id).toBe("T-keep-me")
})
test("does not affect tasks from other teams", () => {
//#given
const task1: TeamTask = {
id: "T-task-1",
subject: "Task 1",
description: "Test",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "ses_1",
}
const task2: TeamTask = {
id: "T-task-2",
subject: "Task 2",
description: "Test",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "ses_2",
}
writeTeamTask("team-a", "T-task-1", task1)
writeTeamTask("team-b", "T-task-2", task2)
//#when
deleteTeamTask("team-a", "T-task-1")
//#then
const remainingInTeamB = listTeamTasks("team-b")
expect(remainingInTeamB).toHaveLength(1)
expect(remainingInTeamB[0].id).toBe("T-task-2")
})
})

View File

@@ -1,165 +0,0 @@
import { existsSync, readdirSync, unlinkSync } from "node:fs"
import { join } from "node:path"
import {
acquireLock,
ensureDir,
generateTaskId,
readJsonSafe,
writeJsonAtomic,
} from "../../features/claude-tasks/storage"
import { getTeamTaskDir, getTeamTaskPath } from "./paths"
import { TeamTask, TeamTaskSchema } from "./types"
import { validateTaskId, validateTeamName } from "./name-validation"
function assertValidTeamName(teamName: string): void {
const validationError = validateTeamName(teamName)
if (validationError) {
throw new Error(validationError)
}
}
function assertValidTaskId(taskId: string): void {
const validationError = validateTaskId(taskId)
if (validationError) {
throw new Error(validationError)
}
}
function withTaskLock<T>(teamName: string, operation: () => T): T {
assertValidTeamName(teamName)
const taskDir = getTeamTaskDir(teamName)
ensureDir(taskDir)
const lock = acquireLock(taskDir)
if (!lock.acquired) {
throw new Error("team_task_lock_unavailable")
}
try {
return operation()
} finally {
lock.release()
}
}
export { getTeamTaskPath } from "./paths"
export function readTeamTask(teamName: string, taskId: string): TeamTask | null {
assertValidTeamName(teamName)
assertValidTaskId(taskId)
return readJsonSafe(getTeamTaskPath(teamName, taskId), TeamTaskSchema)
}
export function readTeamTaskOrThrow(teamName: string, taskId: string): TeamTask {
const task = readTeamTask(teamName, taskId)
if (!task) {
throw new Error("team_task_not_found")
}
return task
}
export function listTeamTasks(teamName: string): TeamTask[] {
assertValidTeamName(teamName)
const taskDir = getTeamTaskDir(teamName)
if (!existsSync(taskDir)) {
return []
}
const files = readdirSync(taskDir)
.filter((file) => file.endsWith(".json") && file.startsWith("T-"))
.sort((a, b) => a.localeCompare(b))
const tasks: TeamTask[] = []
for (const file of files) {
const taskId = file.replace(/\.json$/, "")
if (validateTaskId(taskId)) {
continue
}
const task = readTeamTask(teamName, taskId)
if (task) {
tasks.push(task)
}
}
return tasks
}
export function createTeamTask(
teamName: string,
subject: string,
description: string,
activeForm?: string,
metadata?: Record<string, unknown>,
): TeamTask {
assertValidTeamName(teamName)
if (!subject.trim()) {
throw new Error("team_task_subject_required")
}
return withTaskLock(teamName, () => {
const taskId = generateTaskId()
const task: TeamTask = {
id: taskId,
subject,
description,
activeForm,
status: "pending",
blocks: [],
blockedBy: [],
threadID: `unknown_${taskId}`,
...(metadata ? { metadata } : {}),
}
const validated = TeamTaskSchema.parse(task)
writeJsonAtomic(getTeamTaskPath(teamName, taskId), validated)
return validated
})
}
export function writeTeamTask(teamName: string, taskId: string, task: TeamTask): void {
assertValidTeamName(teamName)
assertValidTaskId(taskId)
const validated = TeamTaskSchema.parse(task)
writeJsonAtomic(getTeamTaskPath(teamName, taskId), validated)
}
export function deleteTeamTask(teamName: string, taskId: string): void {
assertValidTeamName(teamName)
assertValidTaskId(taskId)
const taskPath = getTeamTaskPath(teamName, taskId)
if (existsSync(taskPath)) {
unlinkSync(taskPath)
}
}
// Backward compatibility alias
export function deleteTeamTaskFile(teamName: string, taskId: string): void {
deleteTeamTask(teamName, taskId)
}
export function readTaskFromDirectory(taskDir: string, taskId: string): TeamTask | null {
assertValidTaskId(taskId)
return readJsonSafe(join(taskDir, `${taskId}.json`), TeamTaskSchema)
}
export function resetOwnerTasks(teamName: string, ownerName: string): void {
assertValidTeamName(teamName)
withTaskLock(teamName, () => {
const tasks = listTeamTasks(teamName)
for (const task of tasks) {
if (task.owner !== ownerName) {
continue
}
const next: TeamTask = {
...task,
owner: undefined,
status: task.status === "completed" ? "completed" : "pending",
}
writeTeamTask(teamName, next.id, next)
}
})
}
export function withTeamTaskLock<T>(teamName: string, operation: () => T): T {
assertValidTeamName(teamName)
return withTaskLock(teamName, operation)
}

View File

@@ -1,160 +0,0 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { sendStructuredInboxMessage } from "./inbox-store"
import { readTeamConfigOrThrow } from "./team-config-store"
import { validateAgentNameOrLead, validateTaskId, validateTeamName } from "./name-validation"
import {
TeamConfig,
TeamTaskCreateInputSchema,
TeamTaskGetInputSchema,
TeamTaskListInputSchema,
TeamTask,
TeamToolContext,
isTeammateMember,
} from "./types"
import { createTeamTask, listTeamTasks, readTeamTask } from "./team-task-store"
function buildTaskAssignmentPayload(task: TeamTask, assignedBy: string): Record<string, unknown> {
return {
type: "task_assignment",
taskId: task.id,
subject: task.subject,
description: task.description,
assignedBy,
timestamp: new Date().toISOString(),
}
}
export function resolveTaskActorFromContext(config: TeamConfig, context: TeamToolContext): "team-lead" | string | null {
if (context.sessionID === config.leadSessionId) {
return "team-lead"
}
const matchedMember = config.members.find((member) => isTeammateMember(member) && member.sessionID === context.sessionID)
return matchedMember?.name ?? null
}
export function createTeamTaskCreateTool(): ToolDefinition {
return tool({
description: "Create a task in team-scoped storage.",
args: {
team_name: tool.schema.string().describe("Team name"),
subject: tool.schema.string().describe("Task subject"),
description: tool.schema.string().describe("Task description"),
active_form: tool.schema.string().optional().describe("Present-continuous form"),
metadata: tool.schema.record(tool.schema.string(), tool.schema.unknown()).optional().describe("Task metadata"),
},
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
try {
const input = TeamTaskCreateInputSchema.parse(args)
const teamError = validateTeamName(input.team_name)
if (teamError) {
return JSON.stringify({ error: teamError })
}
const config = readTeamConfigOrThrow(input.team_name)
const actor = resolveTaskActorFromContext(config, context)
if (!actor) {
return JSON.stringify({ error: "unauthorized_task_session" })
}
const task = createTeamTask(
input.team_name,
input.subject,
input.description,
input.active_form,
input.metadata,
)
return JSON.stringify(task)
} catch (error) {
return JSON.stringify({ error: error instanceof Error ? error.message : "team_task_create_failed" })
}
},
})
}
export function createTeamTaskListTool(): ToolDefinition {
return tool({
description: "List tasks for one team.",
args: {
team_name: tool.schema.string().describe("Team name"),
},
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
try {
const input = TeamTaskListInputSchema.parse(args)
const teamError = validateTeamName(input.team_name)
if (teamError) {
return JSON.stringify({ error: teamError })
}
const config = readTeamConfigOrThrow(input.team_name)
const actor = resolveTaskActorFromContext(config, context)
if (!actor) {
return JSON.stringify({ error: "unauthorized_task_session" })
}
return JSON.stringify(listTeamTasks(input.team_name))
} catch (error) {
return JSON.stringify({ error: error instanceof Error ? error.message : "team_task_list_failed" })
}
},
})
}
export function createTeamTaskGetTool(): ToolDefinition {
return tool({
description: "Get one task from team-scoped storage.",
args: {
team_name: tool.schema.string().describe("Team name"),
task_id: tool.schema.string().describe("Task id"),
},
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
try {
const input = TeamTaskGetInputSchema.parse(args)
const teamError = validateTeamName(input.team_name)
if (teamError) {
return JSON.stringify({ error: teamError })
}
const taskIdError = validateTaskId(input.task_id)
if (taskIdError) {
return JSON.stringify({ error: taskIdError })
}
const config = readTeamConfigOrThrow(input.team_name)
const actor = resolveTaskActorFromContext(config, context)
if (!actor) {
return JSON.stringify({ error: "unauthorized_task_session" })
}
const task = readTeamTask(input.team_name, input.task_id)
if (!task) {
return JSON.stringify({ error: "team_task_not_found" })
}
return JSON.stringify(task)
} catch (error) {
return JSON.stringify({ error: error instanceof Error ? error.message : "team_task_get_failed" })
}
},
})
}
export function notifyOwnerAssignment(teamName: string, task: TeamTask, assignedBy: string): void {
if (!task.owner || task.status === "deleted") {
return
}
if (validateTeamName(teamName)) {
return
}
if (validateAgentNameOrLead(task.owner)) {
return
}
if (validateAgentNameOrLead(assignedBy)) {
return
}
sendStructuredInboxMessage(
teamName,
assignedBy,
task.owner,
buildTaskAssignmentPayload(task, assignedBy),
"task_assignment",
)
}

View File

@@ -1,91 +0,0 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { readTeamConfigOrThrow } from "./team-config-store"
import { validateAgentNameOrLead, validateTaskId, validateTeamName } from "./name-validation"
import { TeamTaskUpdateInputSchema, TeamToolContext } from "./types"
import { updateTeamTask } from "./team-task-update"
import { notifyOwnerAssignment, resolveTaskActorFromContext } from "./team-task-tools"
export function createTeamTaskUpdateTool(): ToolDefinition {
return tool({
description: "Update task status, owner, dependencies, and metadata in a team task list.",
args: {
team_name: tool.schema.string().describe("Team name"),
task_id: tool.schema.string().describe("Task id"),
status: tool.schema.enum(["pending", "in_progress", "completed", "deleted"]).optional().describe("Task status"),
owner: tool.schema.string().optional().describe("Task owner"),
subject: tool.schema.string().optional().describe("Task subject"),
description: tool.schema.string().optional().describe("Task description"),
active_form: tool.schema.string().optional().describe("Present-continuous form"),
add_blocks: tool.schema.array(tool.schema.string()).optional().describe("Add task ids this task blocks"),
add_blocked_by: tool.schema.array(tool.schema.string()).optional().describe("Add blocker task ids"),
metadata: tool.schema.record(tool.schema.string(), tool.schema.unknown()).optional().describe("Metadata patch (null removes key)"),
},
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
try {
const input = TeamTaskUpdateInputSchema.parse(args)
const teamError = validateTeamName(input.team_name)
if (teamError) {
return JSON.stringify({ error: teamError })
}
const taskIdError = validateTaskId(input.task_id)
if (taskIdError) {
return JSON.stringify({ error: taskIdError })
}
const config = readTeamConfigOrThrow(input.team_name)
const actor = resolveTaskActorFromContext(config, context)
if (!actor) {
return JSON.stringify({ error: "unauthorized_task_session" })
}
const memberNames = new Set(config.members.map((member) => member.name))
if (input.owner !== undefined) {
if (input.owner !== "") {
const ownerError = validateAgentNameOrLead(input.owner)
if (ownerError) {
return JSON.stringify({ error: ownerError })
}
if (!memberNames.has(input.owner)) {
return JSON.stringify({ error: "owner_not_in_team" })
}
}
}
if (input.add_blocks) {
for (const blockerId of input.add_blocks) {
const blockerError = validateTaskId(blockerId)
if (blockerError) {
return JSON.stringify({ error: blockerError })
}
}
}
if (input.add_blocked_by) {
for (const dependencyId of input.add_blocked_by) {
const dependencyError = validateTaskId(dependencyId)
if (dependencyError) {
return JSON.stringify({ error: dependencyError })
}
}
}
const task = updateTeamTask(input.team_name, input.task_id, {
status: input.status,
owner: input.owner,
subject: input.subject,
description: input.description,
activeForm: input.active_form,
addBlocks: input.add_blocks,
addBlockedBy: input.add_blocked_by,
metadata: input.metadata,
})
if (input.owner !== undefined) {
notifyOwnerAssignment(input.team_name, task, actor)
}
return JSON.stringify(task)
} catch (error) {
return JSON.stringify({ error: error instanceof Error ? error.message : "team_task_update_failed" })
}
},
})
}

View File

@@ -1,247 +0,0 @@
import { existsSync, readdirSync, unlinkSync } from "node:fs"
import { join } from "node:path"
import { readJsonSafe, writeJsonAtomic } from "../../features/claude-tasks/storage"
import { validateTaskId, validateTeamName } from "./name-validation"
import { getTeamTaskDir, getTeamTaskPath } from "./paths"
import {
addPendingEdge,
createPendingEdgeMap,
ensureDependenciesCompleted,
ensureForwardStatusTransition,
wouldCreateCycle,
} from "./team-task-dependency"
import { TeamTask, TeamTaskSchema, TeamTaskStatus } from "./types"
import { withTeamTaskLock } from "./team-task-store"
export interface TeamTaskUpdatePatch {
status?: TeamTaskStatus
owner?: string
subject?: string
description?: string
activeForm?: string
addBlocks?: string[]
addBlockedBy?: string[]
metadata?: Record<string, unknown>
}
function assertValidTeamName(teamName: string): void {
const validationError = validateTeamName(teamName)
if (validationError) {
throw new Error(validationError)
}
}
function assertValidTaskId(taskId: string): void {
const validationError = validateTaskId(taskId)
if (validationError) {
throw new Error(validationError)
}
}
function writeTaskToPath(path: string, task: TeamTask): void {
writeJsonAtomic(path, TeamTaskSchema.parse(task))
}
export function updateTeamTask(teamName: string, taskId: string, patch: TeamTaskUpdatePatch): TeamTask {
assertValidTeamName(teamName)
assertValidTaskId(taskId)
if (patch.addBlocks) {
for (const blockedTaskId of patch.addBlocks) {
assertValidTaskId(blockedTaskId)
}
}
if (patch.addBlockedBy) {
for (const blockerId of patch.addBlockedBy) {
assertValidTaskId(blockerId)
}
}
return withTeamTaskLock(teamName, () => {
const taskDir = getTeamTaskDir(teamName)
const taskPath = getTeamTaskPath(teamName, taskId)
const currentTask = readJsonSafe(taskPath, TeamTaskSchema)
if (!currentTask) {
throw new Error("team_task_not_found")
}
const cache = new Map<string, TeamTask | null>()
cache.set(taskId, currentTask)
const readTask = (id: string): TeamTask | null => {
if (cache.has(id)) {
return cache.get(id) ?? null
}
const loaded = readJsonSafe(join(taskDir, `${id}.json`), TeamTaskSchema)
cache.set(id, loaded)
return loaded
}
const pendingEdges = createPendingEdgeMap()
if (patch.addBlocks) {
for (const blockedTaskId of patch.addBlocks) {
if (blockedTaskId === taskId) {
throw new Error("team_task_self_block")
}
if (!readTask(blockedTaskId)) {
throw new Error(`team_task_reference_not_found:${blockedTaskId}`)
}
addPendingEdge(pendingEdges, blockedTaskId, taskId)
}
for (const blockedTaskId of patch.addBlocks) {
if (wouldCreateCycle(blockedTaskId, taskId, pendingEdges, readTask)) {
throw new Error(`team_task_cycle_detected:${taskId}->${blockedTaskId}`)
}
}
}
if (patch.addBlockedBy) {
for (const blockerId of patch.addBlockedBy) {
if (blockerId === taskId) {
throw new Error("team_task_self_dependency")
}
if (!readTask(blockerId)) {
throw new Error(`team_task_reference_not_found:${blockerId}`)
}
addPendingEdge(pendingEdges, taskId, blockerId)
}
for (const blockerId of patch.addBlockedBy) {
if (wouldCreateCycle(taskId, blockerId, pendingEdges, readTask)) {
throw new Error(`team_task_cycle_detected:${taskId}<-${blockerId}`)
}
}
}
if (patch.status && patch.status !== "deleted") {
ensureForwardStatusTransition(currentTask.status, patch.status)
}
const effectiveStatus = patch.status ?? currentTask.status
const effectiveBlockedBy = Array.from(new Set([...(currentTask.blockedBy ?? []), ...(patch.addBlockedBy ?? [])]))
const shouldValidateDependencies =
(patch.status !== undefined || (patch.addBlockedBy?.length ?? 0) > 0) && effectiveStatus !== "deleted"
if (shouldValidateDependencies) {
ensureDependenciesCompleted(effectiveStatus, effectiveBlockedBy, readTask)
}
let nextTask: TeamTask = { ...currentTask }
if (patch.subject !== undefined) {
nextTask.subject = patch.subject
}
if (patch.description !== undefined) {
nextTask.description = patch.description
}
if (patch.activeForm !== undefined) {
nextTask.activeForm = patch.activeForm
}
if (patch.owner !== undefined) {
nextTask.owner = patch.owner === "" ? undefined : patch.owner
}
const pendingWrites = new Map<string, TeamTask>()
if (patch.addBlocks) {
const existingBlocks = new Set(nextTask.blocks)
for (const blockedTaskId of patch.addBlocks) {
if (!existingBlocks.has(blockedTaskId)) {
nextTask.blocks.push(blockedTaskId)
existingBlocks.add(blockedTaskId)
}
const otherPath = getTeamTaskPath(teamName, blockedTaskId)
const other = pendingWrites.get(otherPath) ?? readTask(blockedTaskId)
if (other && !other.blockedBy.includes(taskId)) {
pendingWrites.set(otherPath, { ...other, blockedBy: [...other.blockedBy, taskId] })
}
}
}
if (patch.addBlockedBy) {
const existingBlockedBy = new Set(nextTask.blockedBy)
for (const blockerId of patch.addBlockedBy) {
if (!existingBlockedBy.has(blockerId)) {
nextTask.blockedBy.push(blockerId)
existingBlockedBy.add(blockerId)
}
const otherPath = getTeamTaskPath(teamName, blockerId)
const other = pendingWrites.get(otherPath) ?? readTask(blockerId)
if (other && !other.blocks.includes(taskId)) {
pendingWrites.set(otherPath, { ...other, blocks: [...other.blocks, taskId] })
}
}
}
if (patch.metadata !== undefined) {
const merged: Record<string, unknown> = { ...(nextTask.metadata ?? {}) }
for (const [key, value] of Object.entries(patch.metadata)) {
if (value === null) {
delete merged[key]
} else {
merged[key] = value
}
}
nextTask.metadata = Object.keys(merged).length > 0 ? merged : undefined
}
if (patch.status !== undefined) {
nextTask.status = patch.status
}
const allTaskFiles = readdirSync(taskDir).filter((file) => file.endsWith(".json") && file.startsWith("T-"))
if (nextTask.status === "completed") {
for (const file of allTaskFiles) {
const otherId = file.replace(/\.json$/, "")
if (otherId === taskId) continue
const otherPath = getTeamTaskPath(teamName, otherId)
const other = pendingWrites.get(otherPath) ?? readTask(otherId)
if (other?.blockedBy.includes(taskId)) {
pendingWrites.set(otherPath, {
...other,
blockedBy: other.blockedBy.filter((id) => id !== taskId),
})
}
}
}
if (patch.status === "deleted") {
for (const file of allTaskFiles) {
const otherId = file.replace(/\.json$/, "")
if (otherId === taskId) continue
const otherPath = getTeamTaskPath(teamName, otherId)
const other = pendingWrites.get(otherPath) ?? readTask(otherId)
if (!other) continue
const nextOther = {
...other,
blockedBy: other.blockedBy.filter((id) => id !== taskId),
blocks: other.blocks.filter((id) => id !== taskId),
}
pendingWrites.set(otherPath, nextOther)
}
}
for (const [path, task] of pendingWrites.entries()) {
writeTaskToPath(path, task)
}
if (patch.status === "deleted") {
if (existsSync(taskPath)) {
unlinkSync(taskPath)
}
return TeamTaskSchema.parse({ ...nextTask, status: "deleted" })
}
writeTaskToPath(taskPath, nextTask)
return TeamTaskSchema.parse(nextTask)
})
}

View File

@@ -1,243 +0,0 @@
/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import { existsSync, mkdtempSync, rmSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { randomUUID } from "node:crypto"
import { createForceKillTeammateTool, createProcessShutdownApprovedTool } from "./teammate-control-tools"
import { readTeamConfig } from "./team-config-store"
import { upsertTeammate, writeTeamConfig } from "./team-config-store"
import { ensureInbox } from "./inbox-store"
const TEST_SUFFIX = randomUUID().substring(0, 8)
interface TestToolContext {
sessionID: string
messageID: string
agent: string
abort: AbortSignal
}
function createContext(sessionID = "ses-main"): TestToolContext {
return {
sessionID,
messageID: "msg-main",
agent: "sisyphus",
abort: new AbortController().signal as AbortSignal,
}
}
async function executeJsonTool(
tool: any,
args: Record<string, unknown>,
context: TestToolContext,
): Promise<unknown> {
const output = await tool.execute(args, context)
return JSON.parse(output)
}
describe("teammate-control-tools", () => {
let originalCwd: string
let tempProjectDir: string
const teamName = `test-team-control-${TEST_SUFFIX}`
beforeEach(() => {
originalCwd = process.cwd()
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-control-"))
process.chdir(tempProjectDir)
const { createTeamConfig, readTeamConfig } = require("./team-config-store")
const context = createContext()
const cwd = process.cwd()
if (!readTeamConfig(teamName)) {
createTeamConfig(
teamName,
"Test team",
context.sessionID,
cwd,
"native/team-lead",
)
}
ensureInbox(teamName, "team-lead")
})
afterEach(() => {
process.chdir(originalCwd)
if (existsSync(tempProjectDir)) {
rmSync(tempProjectDir, { recursive: true, force: true })
}
})
describe("createForceKillTeammateTool", () => {
it("returns error when team not found", async () => {
const tool = createForceKillTeammateTool()
const testContext = createContext()
const result = await executeJsonTool(
tool,
{ team_name: "nonexistent-team", teammate_name: "test-teammate" },
testContext,
)
expect(result).toHaveProperty("error")
})
it("returns error when trying to remove team-lead", async () => {
const tool = createForceKillTeammateTool()
const testContext = createContext()
const result = await executeJsonTool(
tool,
{ team_name: teamName, teammate_name: "team-lead" },
testContext,
)
expect(result).toHaveProperty("error", "cannot_remove_team_lead")
})
it("returns error when teammate does not exist", async () => {
const tool = createForceKillTeammateTool()
const testContext = createContext()
const result = await executeJsonTool(
tool,
{ team_name: teamName, teammate_name: "nonexistent-teammate" },
testContext,
)
expect(result).toHaveProperty("error", "teammate_not_found")
})
it("removes teammate from config and deletes inbox", async () => {
const config = readTeamConfig(teamName)!
const currentCwd = process.cwd()
const teammate = {
agentId: `test-teammate-${TEST_SUFFIX}@${teamName}`,
name: `test-teammate-${TEST_SUFFIX}`,
agentType: "teammate" as const,
category: "quick",
model: "gpt-5-mini",
prompt: "Test prompt",
planModeRequired: false,
joinedAt: new Date().toISOString(),
cwd: currentCwd,
subscriptions: [],
backendType: "native" as const,
isActive: true,
sessionID: `ses_teammate-${TEST_SUFFIX}`,
backgroundTaskID: undefined,
color: "#FF6B6B",
}
const updatedConfig = upsertTeammate(config, teammate)
writeTeamConfig(teamName, updatedConfig)
ensureInbox(teamName, `test-teammate-${TEST_SUFFIX}`)
const tool = createForceKillTeammateTool()
const testContext = createContext()
const result = await executeJsonTool(
tool,
{ team_name: teamName, teammate_name: `test-teammate-${TEST_SUFFIX}` },
testContext,
)
expect(result).toHaveProperty("killed", true)
expect(result).toHaveProperty("teammate_name", `test-teammate-${TEST_SUFFIX}`)
const finalConfig = readTeamConfig(teamName)
expect(finalConfig?.members.some((m) => m.name === `test-teammate-${TEST_SUFFIX}`)).toBe(false)
const inboxPath = `.sisyphus/teams/${teamName}/inbox/test-teammate-${TEST_SUFFIX}.json`
expect(existsSync(inboxPath)).toBe(false)
})
})
describe("createProcessShutdownApprovedTool", () => {
it("returns error when team not found", async () => {
const tool = createProcessShutdownApprovedTool()
const testContext = createContext()
const result = await executeJsonTool(
tool,
{ team_name: "nonexistent-team", teammate_name: "test-teammate" },
testContext,
)
expect(result).toHaveProperty("error")
})
it("returns error when trying to remove team-lead", async () => {
const tool = createProcessShutdownApprovedTool()
const testContext = createContext()
const result = await executeJsonTool(
tool,
{ team_name: teamName, teammate_name: "team-lead" },
testContext,
)
expect(result).toHaveProperty("error", "cannot_remove_team_lead")
})
it("returns error when teammate does not exist", async () => {
const tool = createProcessShutdownApprovedTool()
const testContext = createContext()
const result = await executeJsonTool(
tool,
{ team_name: teamName, teammate_name: "nonexistent-teammate" },
testContext,
)
expect(result).toHaveProperty("error", "teammate_not_found")
})
it("removes teammate from config and deletes inbox gracefully", async () => {
const config = readTeamConfig(teamName)!
const currentCwd = process.cwd()
const teammateName = `test-teammate2-${TEST_SUFFIX}`
const teammate = {
agentId: `${teammateName}@${teamName}`,
name: teammateName,
agentType: "teammate" as const,
category: "quick",
model: "gpt-5-mini",
prompt: "Test prompt",
planModeRequired: false,
joinedAt: new Date().toISOString(),
cwd: currentCwd,
subscriptions: [],
backendType: "native" as const,
isActive: true,
sessionID: `ses_${teammateName}`,
backgroundTaskID: undefined,
color: "#4ECDC4",
}
const updatedConfig = upsertTeammate(config, teammate)
writeTeamConfig(teamName, updatedConfig)
ensureInbox(teamName, teammateName)
const tool = createProcessShutdownApprovedTool()
const testContext = createContext()
const result = await executeJsonTool(
tool,
{ team_name: teamName, teammate_name: teammateName },
testContext,
)
expect(result).toHaveProperty("shutdown_processed", true)
expect(result).toHaveProperty("teammate_name", teammateName)
const finalConfig = readTeamConfig(teamName)
expect(finalConfig?.members.some((m) => m.name === teammateName)).toBe(false)
const inboxPath = `.sisyphus/teams/${teamName}/inbox/${teammateName}.json`
expect(existsSync(inboxPath)).toBe(false)
})
})
})

View File

@@ -1,103 +0,0 @@
import { tool } from "@opencode-ai/plugin/tool"
import { ForceKillTeammateInputSchema, ProcessShutdownApprovedInputSchema, isTeammateMember } from "./types"
import { readTeamConfig, removeTeammate, updateTeamConfig, getTeamMember } from "./team-config-store"
import { deleteInbox } from "./inbox-store"
export function createForceKillTeammateTool() {
return tool({
description: "Force kill a teammate - remove from team config and delete inbox without graceful shutdown.",
args: {
team_name: tool.schema.string().describe("Team name"),
teammate_name: tool.schema.string().describe("Teammate name to kill"),
},
execute: async (args: Record<string, unknown>): Promise<string> => {
try {
const input = ForceKillTeammateInputSchema.parse(args)
const config = readTeamConfig(input.team_name)
if (!config) {
return JSON.stringify({ error: "team_not_found" })
}
const teammate = getTeamMember(config, input.teammate_name)
if (!teammate) {
return JSON.stringify({ error: "teammate_not_found" })
}
if (input.teammate_name === "team-lead") {
return JSON.stringify({ error: "cannot_remove_team_lead" })
}
if (!isTeammateMember(teammate)) {
return JSON.stringify({ error: "not_a_teammate" })
}
updateTeamConfig(input.team_name, (config) => removeTeammate(config, input.teammate_name))
deleteInbox(input.team_name, input.teammate_name)
return JSON.stringify({
killed: true,
teammate_name: input.teammate_name,
})
} catch (error) {
if (error instanceof Error) {
if (error.message === "cannot_remove_team_lead") {
return JSON.stringify({ error: "cannot_remove_team_lead" })
}
return JSON.stringify({ error: error.message })
}
return JSON.stringify({ error: "force_kill_failed" })
}
},
})
}
export function createProcessShutdownApprovedTool() {
return tool({
description:
"Process approved teammate shutdown - remove from team config and delete inbox gracefully.",
args: {
team_name: tool.schema.string().describe("Team name"),
teammate_name: tool.schema.string().describe("Teammate name to shutdown"),
},
execute: async (args: Record<string, unknown>): Promise<string> => {
try {
const input = ProcessShutdownApprovedInputSchema.parse(args)
const config = readTeamConfig(input.team_name)
if (!config) {
return JSON.stringify({ error: "team_not_found" })
}
const teammate = getTeamMember(config, input.teammate_name)
if (!teammate) {
return JSON.stringify({ error: "teammate_not_found" })
}
if (input.teammate_name === "team-lead") {
return JSON.stringify({ error: "cannot_remove_team_lead" })
}
if (!isTeammateMember(teammate)) {
return JSON.stringify({ error: "not_a_teammate" })
}
updateTeamConfig(input.team_name, (config) => removeTeammate(config, input.teammate_name))
deleteInbox(input.team_name, input.teammate_name)
return JSON.stringify({
shutdown_processed: true,
teammate_name: input.teammate_name,
})
} catch (error) {
if (error instanceof Error) {
if (error.message === "cannot_remove_team_lead") {
return JSON.stringify({ error: "cannot_remove_team_lead" })
}
return JSON.stringify({ error: error.message })
}
return JSON.stringify({ error: "shutdown_processing_failed" })
}
},
})
}

View File

@@ -1,36 +0,0 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import { buildTeamParentToolContext } from "./teammate-parent-context"
describe("agent-teams teammate parent context", () => {
test("forwards incoming abort signal to parent context resolver", () => {
//#given
const abortSignal = new AbortController().signal
//#when
const parentToolContext = buildTeamParentToolContext({
sessionID: "ses-main",
messageID: "msg-main",
agent: "sisyphus",
abort: abortSignal,
})
//#then
expect(parentToolContext.abort).toBe(abortSignal)
expect(parentToolContext.sessionID).toBe("ses-main")
expect(parentToolContext.messageID).toBe("msg-main")
expect(parentToolContext.agent).toBe("sisyphus")
})
test("leaves agent undefined if missing in tool context", () => {
//#when
const parentToolContext = buildTeamParentToolContext({
sessionID: "ses-main",
messageID: "msg-main",
abort: new AbortController().signal,
})
//#then
expect(parentToolContext.agent).toBeUndefined()
})
})

View File

@@ -1,17 +0,0 @@
import type { ParentContext } from "../delegate-task/executor"
import { resolveParentContext } from "../delegate-task/executor"
import type { ToolContextWithMetadata } from "../delegate-task/types"
import type { TeamToolContext } from "./types"
export function buildTeamParentToolContext(context: TeamToolContext): ToolContextWithMetadata {
return {
sessionID: context.sessionID,
messageID: context.messageID,
agent: context.agent,
abort: context.abort ?? new AbortController().signal,
}
}
export function resolveTeamParentContext(context: TeamToolContext): ParentContext {
return resolveParentContext(buildTeamParentToolContext(context))
}

View File

@@ -1,28 +0,0 @@
export function buildLaunchPrompt(
teamName: string,
teammateName: string,
userPrompt: string,
categoryPromptAppend?: string,
): string {
const sections = [
`You are teammate "${teammateName}" in team "${teamName}".`,
`When you need updates, call read_inbox with team_name="${teamName}" and agent_name="${teammateName}".`,
"Initial assignment:",
userPrompt,
]
if (categoryPromptAppend) {
sections.push("Category guidance:", categoryPromptAppend)
}
return sections.join("\n\n")
}
export function buildDeliveryPrompt(teamName: string, teammateName: string, summary: string, content: string): string {
return [
`New team message for "${teammateName}" in team "${teamName}".`,
`Summary: ${summary}`,
"Content:",
content,
].join("\n\n")
}

View File

@@ -1,197 +0,0 @@
import type { BackgroundManager } from "../../features/background-agent"
import { clearInbox, ensureInbox, sendPlainInboxMessage } from "./inbox-store"
import { assignNextColor, getTeamMember, removeTeammate, updateTeamConfig, upsertTeammate } from "./team-config-store"
import type { TeamTeammateMember, TeamToolContext } from "./types"
import { resolveTeamParentContext } from "./teammate-parent-context"
import { buildDeliveryPrompt, buildLaunchPrompt } from "./teammate-prompts"
import { resolveSpawnExecution, type TeamCategoryContext } from "./teammate-spawn-execution"
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function resolveLaunchFailureMessage(status: string | undefined, error: string | undefined): string {
if (status === "error") {
return error ? `teammate_launch_failed:${error}` : "teammate_launch_failed"
}
if (status === "cancelled") {
return "teammate_launch_cancelled"
}
return "teammate_launch_timeout"
}
export interface SpawnTeammateParams {
teamName: string
name: string
prompt: string
category: string
subagentType: string
model?: string
planModeRequired: boolean
context: TeamToolContext
manager: BackgroundManager
categoryContext?: TeamCategoryContext
}
export async function spawnTeammate(params: SpawnTeammateParams): Promise<TeamTeammateMember> {
const parentContext = resolveTeamParentContext(params.context)
const execution = await resolveSpawnExecution(
{
teamName: params.teamName,
name: params.name,
prompt: params.prompt,
category: params.category,
subagentType: params.subagentType,
model: params.model,
manager: params.manager,
categoryContext: params.categoryContext,
},
parentContext,
)
let teammate: TeamTeammateMember | undefined
let launchedTaskID: string | undefined
updateTeamConfig(params.teamName, (current) => {
if (getTeamMember(current, params.name)) {
throw new Error("teammate_already_exists")
}
teammate = {
agentId: `${params.name}@${params.teamName}`,
name: params.name,
agentType: "teammate",
category: params.category,
model: execution.teammateModel,
prompt: params.prompt,
color: assignNextColor(current),
planModeRequired: params.planModeRequired,
joinedAt: new Date().toISOString(),
cwd: process.cwd(),
subscriptions: [],
backendType: "native",
isActive: false,
}
return upsertTeammate(current, teammate)
})
if (!teammate) {
throw new Error("teammate_create_failed")
}
try {
ensureInbox(params.teamName, params.name)
sendPlainInboxMessage(params.teamName, "team-lead", params.name, params.prompt, "initial_prompt", teammate.color)
const launched = await params.manager.launch({
description: `[team:${params.teamName}] ${params.name}`,
prompt: buildLaunchPrompt(params.teamName, params.name, params.prompt, execution.categoryPromptAppend),
agent: execution.agentType,
parentSessionID: parentContext.sessionID,
parentMessageID: parentContext.messageID,
parentModel: parentContext.model,
...(execution.launchModel ? { model: execution.launchModel } : {}),
...(params.category ? { category: params.category } : {}),
parentAgent: parentContext.agent,
})
launchedTaskID = launched.id
const start = Date.now()
let sessionID = launched.sessionID
let latestStatus: string | undefined
let latestError: string | undefined
while (!sessionID && Date.now() - start < 30_000) {
await delay(50)
const task = params.manager.getTask(launched.id)
latestStatus = task?.status
latestError = task?.error
if (task?.status === "error" || task?.status === "cancelled") {
throw new Error(resolveLaunchFailureMessage(task.status, task.error))
}
sessionID = task?.sessionID
}
if (!sessionID) {
throw new Error(resolveLaunchFailureMessage(latestStatus, latestError))
}
const nextMember: TeamTeammateMember = {
...teammate,
isActive: true,
backgroundTaskID: launched.id,
sessionID,
}
updateTeamConfig(params.teamName, (current) => upsertTeammate(current, nextMember))
return nextMember
} catch (error) {
const originalError = error
if (launchedTaskID) {
await params.manager
.cancelTask(launchedTaskID, {
source: "team_launch_failed",
abortSession: true,
skipNotification: true,
})
.catch(() => undefined)
}
try {
updateTeamConfig(params.teamName, (current) => removeTeammate(current, params.name))
} catch (cleanupError) {
void cleanupError
}
try {
clearInbox(params.teamName, params.name)
} catch (cleanupError) {
void cleanupError
}
throw originalError
}
}
export async function resumeTeammateWithMessage(
manager: BackgroundManager,
context: TeamToolContext,
teamName: string,
teammate: TeamTeammateMember,
summary: string,
content: string,
): Promise<void> {
if (!teammate.sessionID) {
return
}
const parentContext = resolveTeamParentContext(context)
try {
await manager.resume({
sessionId: teammate.sessionID,
prompt: buildDeliveryPrompt(teamName, teammate.name, summary, content),
parentSessionID: parentContext.sessionID,
parentMessageID: parentContext.messageID,
parentModel: parentContext.model,
parentAgent: parentContext.agent,
})
} catch {
return
}
}
export async function cancelTeammateRun(manager: BackgroundManager, teammate: TeamTeammateMember): Promise<void> {
if (!teammate.backgroundTaskID) {
return
}
await manager.cancelTask(teammate.backgroundTaskID, {
source: "team_force_kill",
abortSession: true,
skipNotification: true,
})
}

View File

@@ -1,119 +0,0 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { CategoriesConfig } from "../../config/schema"
import type { BackgroundManager } from "../../features/background-agent"
import type { ParentContext } from "../delegate-task/executor"
import { resolveCategoryExecution } from "../delegate-task/executor"
import type { DelegateTaskArgs } from "../delegate-task/types"
function parseModel(model: string | undefined): { providerID: string; modelID: string } | undefined {
if (!model) {
return undefined
}
const separatorIndex = model.indexOf("/")
if (separatorIndex <= 0 || separatorIndex >= model.length - 1) {
throw new Error("invalid_model_override_format")
}
return {
providerID: model.slice(0, separatorIndex),
modelID: model.slice(separatorIndex + 1),
}
}
async function getSystemDefaultModel(client: PluginInput["client"]): Promise<string | undefined> {
try {
const openCodeConfig = await client.config.get()
return (openCodeConfig as { data?: { model?: string } })?.data?.model
} catch {
return undefined
}
}
export interface TeamCategoryContext {
client: PluginInput["client"]
userCategories?: CategoriesConfig
sisyphusJuniorModel?: string
}
export interface SpawnExecutionRequest {
teamName: string
name: string
prompt: string
category: string
subagentType: string
model?: string
manager: BackgroundManager
categoryContext?: TeamCategoryContext
}
export interface SpawnExecutionResult {
agentType: string
teammateModel: string
launchModel?: { providerID: string; modelID: string; variant?: string }
categoryPromptAppend?: string
}
export async function resolveSpawnExecution(
request: SpawnExecutionRequest,
parentContext: ParentContext,
): Promise<SpawnExecutionResult> {
if (request.model) {
const launchModel = parseModel(request.model)
return {
agentType: request.subagentType,
teammateModel: request.model,
...(launchModel ? { launchModel } : {}),
}
}
if (!request.categoryContext?.client) {
return {
agentType: request.subagentType,
teammateModel: "native",
}
}
const inheritedModel = parentContext.model
? `${parentContext.model.providerID}/${parentContext.model.modelID}`
: undefined
const systemDefaultModel = await getSystemDefaultModel(request.categoryContext.client)
const delegateArgs: DelegateTaskArgs = {
description: `[team:${request.teamName}] ${request.name}`,
prompt: request.prompt,
category: request.category,
subagent_type: "sisyphus-junior",
run_in_background: true,
load_skills: [],
}
const resolution = await resolveCategoryExecution(
delegateArgs,
{
manager: request.manager,
client: request.categoryContext.client,
directory: process.cwd(),
userCategories: request.categoryContext.userCategories,
sisyphusJuniorModel: request.categoryContext.sisyphusJuniorModel,
},
inheritedModel,
systemDefaultModel,
)
if (resolution.error) {
throw new Error(resolution.error)
}
if (!resolution.categoryModel) {
throw new Error("category_model_not_resolved")
}
return {
agentType: resolution.agentToUse,
teammateModel: `${resolution.categoryModel.providerID}/${resolution.categoryModel.modelID}`,
launchModel: resolution.categoryModel,
categoryPromptAppend: resolution.categoryPromptAppend,
}
}

View File

@@ -1,97 +0,0 @@
/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { mkdtempSync, rmSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import type { BackgroundManager } from "../../features/background-agent"
import { createAgentTeamsTools } from "./tools"
interface TestToolContext {
sessionID: string
messageID: string
agent: string
abort: AbortSignal
}
interface MockManagerHandles {
manager: BackgroundManager
launchCalls: Array<Record<string, unknown>>
}
function createMockManager(): MockManagerHandles {
const launchCalls: Array<Record<string, unknown>> = []
const manager = {
launch: async (args: Record<string, unknown>) => {
launchCalls.push(args)
return { id: `bg-${launchCalls.length}`, sessionID: `ses-worker-${launchCalls.length}` }
},
getTask: () => undefined,
resume: async () => ({ id: "resume-1" }),
cancelTask: async () => true,
} as unknown as BackgroundManager
return { manager, launchCalls }
}
function createContext(sessionID = "ses-main"): TestToolContext {
return {
sessionID,
messageID: "msg-main",
agent: "sisyphus",
abort: new AbortController().signal,
}
}
async function executeJsonTool(
tools: ReturnType<typeof createAgentTeamsTools>,
toolName: keyof ReturnType<typeof createAgentTeamsTools>,
args: Record<string, unknown>,
context: TestToolContext,
): Promise<unknown> {
const output = await tools[toolName].execute(args, context)
return JSON.parse(output)
}
describe("agent-teams teammate tools", () => {
let originalCwd: string
let tempProjectDir: string
beforeEach(() => {
originalCwd = process.cwd()
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-teammate-tools-"))
process.chdir(tempProjectDir)
})
afterEach(() => {
process.chdir(originalCwd)
rmSync(tempProjectDir, { recursive: true, force: true })
})
test("spawn_teammate requires lead session authorization", async () => {
//#given
const { manager, launchCalls } = createMockManager()
const tools = createAgentTeamsTools(manager)
const leadContext = createContext("ses-lead")
const teammateContext = createContext("ses-worker")
await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext)
//#when
const unauthorized = await executeJsonTool(
tools,
"spawn_teammate",
{
team_name: "core",
name: "worker_1",
prompt: "Handle release prep",
category: "quick",
},
teammateContext,
) as { error?: string }
//#then
expect(unauthorized.error).toBe("unauthorized_lead_session")
expect(launchCalls).toHaveLength(0)
})
})

View File

@@ -1,198 +0,0 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import type { PluginInput } from "@opencode-ai/plugin"
import type { CategoriesConfig } from "../../config/schema"
import type { BackgroundManager } from "../../features/background-agent"
import { clearInbox } from "./inbox-store"
import { validateAgentName, validateTeamName } from "./name-validation"
import {
TeamForceKillInputSchema,
TeamProcessShutdownInputSchema,
TeamSpawnInputSchema,
TeamToolContext,
isTeammateMember,
} from "./types"
import { getTeamMember, readTeamConfigOrThrow, removeTeammate, updateTeamConfig } from "./team-config-store"
import { cancelTeammateRun, spawnTeammate } from "./teammate-runtime"
import { resetOwnerTasks } from "./team-task-store"
export interface AgentTeamsSpawnOptions {
client?: PluginInput["client"]
userCategories?: CategoriesConfig
sisyphusJuniorModel?: string
}
async function shutdownTeammateWithCleanup(
manager: BackgroundManager,
context: TeamToolContext,
teamName: string,
agentName: string,
): Promise<string | null> {
const config = readTeamConfigOrThrow(teamName)
if (context.sessionID !== config.leadSessionId) {
return "unauthorized_lead_session"
}
const member = getTeamMember(config, agentName)
if (!member || !isTeammateMember(member)) {
return "teammate_not_found"
}
await cancelTeammateRun(manager, member)
let removed = false
updateTeamConfig(teamName, (current) => {
const refreshedMember = getTeamMember(current, agentName)
if (!refreshedMember || !isTeammateMember(refreshedMember)) {
return current
}
removed = true
return removeTeammate(current, agentName)
})
if (removed) {
clearInbox(teamName, agentName)
}
resetOwnerTasks(teamName, agentName)
return null
}
export function createSpawnTeammateTool(manager: BackgroundManager, options?: AgentTeamsSpawnOptions): ToolDefinition {
return tool({
description: "Spawn a teammate using native internal agent execution.",
args: {
team_name: tool.schema.string().describe("Team name"),
name: tool.schema.string().describe("Teammate name"),
prompt: tool.schema.string().describe("Initial teammate prompt"),
category: tool.schema.string().describe("Required category for teammate metadata and routing"),
subagent_type: tool.schema.string().optional().describe("Agent name to run (default: sisyphus-junior)"),
model: tool.schema.string().optional().describe("Optional model override in provider/model format"),
plan_mode_required: tool.schema.boolean().optional().describe("Enable plan mode flag in teammate metadata"),
},
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
try {
const input = TeamSpawnInputSchema.parse(args)
const teamError = validateTeamName(input.team_name)
if (teamError) {
return JSON.stringify({ error: teamError })
}
const agentError = validateAgentName(input.name)
if (agentError) {
return JSON.stringify({ error: agentError })
}
if (!input.category.trim()) {
return JSON.stringify({ error: "category_required" })
}
if (input.subagent_type && input.subagent_type !== "sisyphus-junior") {
return JSON.stringify({ error: "category_conflicts_with_subagent_type" })
}
const config = readTeamConfigOrThrow(input.team_name)
if (context.sessionID !== config.leadSessionId) {
return JSON.stringify({ error: "unauthorized_lead_session" })
}
const resolvedSubagentType = input.subagent_type ?? "sisyphus-junior"
const teammate = await spawnTeammate({
teamName: input.team_name,
name: input.name,
prompt: input.prompt,
category: input.category,
subagentType: resolvedSubagentType,
model: input.model,
planModeRequired: input.plan_mode_required ?? false,
context,
manager,
categoryContext: options?.client
? {
client: options.client,
userCategories: options.userCategories,
sisyphusJuniorModel: options.sisyphusJuniorModel,
}
: undefined,
})
return JSON.stringify({
agent_id: teammate.agentId,
name: teammate.name,
team_name: input.team_name,
session_id: teammate.sessionID,
task_id: teammate.backgroundTaskID,
})
} catch (error) {
return JSON.stringify({ error: error instanceof Error ? error.message : "spawn_teammate_failed" })
}
},
})
}
export function createForceKillTeammateTool(manager: BackgroundManager): ToolDefinition {
return tool({
description: "Force stop a teammate and clean up ownership state.",
args: {
team_name: tool.schema.string().describe("Team name"),
teammate_name: tool.schema.string().describe("Teammate name"),
},
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
try {
const input = TeamForceKillInputSchema.parse(args)
const teamError = validateTeamName(input.team_name)
if (teamError) {
return JSON.stringify({ error: teamError })
}
const agentError = validateAgentName(input.teammate_name)
if (agentError) {
return JSON.stringify({ error: agentError })
}
const shutdownError = await shutdownTeammateWithCleanup(manager, context, input.team_name, input.teammate_name)
if (shutdownError) {
return JSON.stringify({ error: shutdownError })
}
return JSON.stringify({ success: true, message: `${input.teammate_name} stopped` })
} catch (error) {
return JSON.stringify({ error: error instanceof Error ? error.message : "force_kill_teammate_failed" })
}
},
})
}
export function createProcessShutdownTool(manager: BackgroundManager): ToolDefinition {
return tool({
description: "Finalize an approved shutdown by removing teammate and resetting owned tasks.",
args: {
team_name: tool.schema.string().describe("Team name"),
teammate_name: tool.schema.string().describe("Teammate name"),
},
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
try {
const input = TeamProcessShutdownInputSchema.parse(args)
const teamError = validateTeamName(input.team_name)
if (teamError) {
return JSON.stringify({ error: teamError })
}
if (input.teammate_name === "team-lead") {
return JSON.stringify({ error: "cannot_shutdown_team_lead" })
}
const agentError = validateAgentName(input.teammate_name)
if (agentError) {
return JSON.stringify({ error: agentError })
}
const shutdownError = await shutdownTeammateWithCleanup(manager, context, input.team_name, input.teammate_name)
if (shutdownError) {
return JSON.stringify({ error: shutdownError })
}
return JSON.stringify({ success: true, message: `${input.teammate_name} removed` })
} catch (error) {
return JSON.stringify({ error: error instanceof Error ? error.message : "process_shutdown_failed" })
}
},
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +0,0 @@
import type { ToolDefinition } from "@opencode-ai/plugin"
import type { BackgroundManager } from "../../features/background-agent"
import type { PluginInput } from "@opencode-ai/plugin"
import type { CategoriesConfig } from "../../config/schema"
import { createReadInboxTool, createSendMessageTool } from "./messaging-tools"
import { createTeamCreateTool, createTeamDeleteTool, createTeamReadConfigTool } from "./team-lifecycle-tools"
import { createForceKillTeammateTool, createProcessShutdownApprovedTool } from "./teammate-control-tools"
export interface AgentTeamsToolOptions {
client?: PluginInput["client"]
userCategories?: CategoriesConfig
sisyphusJuniorModel?: string
}
export function createAgentTeamsTools(
_manager: BackgroundManager,
_options?: AgentTeamsToolOptions,
): Record<string, ToolDefinition> {
return {
team_create: createTeamCreateTool(),
team_delete: createTeamDeleteTool(),
send_message: createSendMessageTool(_manager),
read_inbox: createReadInboxTool(),
read_config: createTeamReadConfigTool(),
force_kill_teammate: createForceKillTeammateTool(),
process_shutdown_approved: createProcessShutdownApprovedTool(),
}
}

View File

@@ -1,721 +0,0 @@
import { describe, it, expect } from "bun:test"
import { z } from "zod"
import {
TeamConfigSchema,
TeamMemberSchema,
TeamTeammateMemberSchema,
MessageTypeSchema,
InboxMessageSchema,
TeamTaskSchema,
TeamCreateInputSchema,
TeamDeleteInputSchema,
SendMessageInputSchema,
ReadInboxInputSchema,
ReadConfigInputSchema,
TeamSpawnInputSchema,
ForceKillTeammateInputSchema,
ProcessShutdownApprovedInputSchema,
} from "./types"
describe("TeamConfigSchema", () => {
it("validates a complete team config", () => {
// given
const validConfig = {
name: "my-team",
description: "A test team",
createdAt: "2026-02-11T10:00:00Z",
leadAgentId: "agent-123",
leadSessionId: "ses-456",
members: [
{
agentId: "agent-123",
name: "Lead Agent",
agentType: "lead",
color: "blue",
},
{
agentId: "agent-789",
name: "Worker 1",
agentType: "teammate",
color: "green",
category: "quick",
model: "claude-sonnet-4-5",
prompt: "You are a helpful assistant",
planModeRequired: false,
joinedAt: "2026-02-11T10:05:00Z",
cwd: "/tmp",
subscriptions: ["task-updates"],
backendType: "native" as const,
isActive: true,
sessionID: "ses-789",
backgroundTaskID: "task-123",
},
],
}
// when
const result = TeamConfigSchema.safeParse(validConfig)
// then
expect(result.success).toBe(true)
})
it("rejects invalid team config", () => {
// given
const invalidConfig = {
name: "",
description: "A test team",
createdAt: "invalid-date",
leadAgentId: "",
leadSessionId: "ses-456",
members: [],
}
// when
const result = TeamConfigSchema.safeParse(invalidConfig)
// then
expect(result.success).toBe(false)
})
})
describe("TeamMemberSchema", () => {
it("validates a lead member", () => {
// given
const leadMember = {
agentId: "agent-123",
name: "Lead Agent",
agentType: "lead",
color: "blue",
}
// when
const result = TeamMemberSchema.safeParse(leadMember)
// then
expect(result.success).toBe(true)
})
it("rejects invalid member", () => {
// given
const invalidMember = {
agentId: "",
name: "",
agentType: "invalid",
color: "invalid",
}
// when
const result = TeamMemberSchema.safeParse(invalidMember)
// then
expect(result.success).toBe(false)
})
})
describe("TeamTeammateMemberSchema", () => {
it("validates a complete teammate member", () => {
// given
const teammateMember = {
agentId: "agent-789",
name: "Worker 1",
agentType: "teammate",
color: "green",
category: "quick",
model: "claude-sonnet-4-5",
prompt: "You are a helpful assistant",
planModeRequired: false,
joinedAt: "2026-02-11T10:05:00Z",
cwd: "/tmp",
subscriptions: ["task-updates"],
backendType: "native" as const,
isActive: true,
sessionID: "ses-789",
backgroundTaskID: "task-123",
}
// when
const result = TeamTeammateMemberSchema.safeParse(teammateMember)
// then
expect(result.success).toBe(true)
})
it("validates teammate member with optional fields missing", () => {
// given
const minimalTeammate = {
agentId: "agent-789",
name: "Worker 1",
agentType: "teammate",
color: "green",
category: "quick",
model: "claude-sonnet-4-5",
prompt: "You are a helpful assistant",
planModeRequired: false,
joinedAt: "2026-02-11T10:05:00Z",
cwd: "/tmp",
subscriptions: [],
backendType: "native" as const,
isActive: true,
}
// when
const result = TeamTeammateMemberSchema.safeParse(minimalTeammate)
// then
expect(result.success).toBe(true)
})
it("rejects invalid teammate member", () => {
// given
const invalidTeammate = {
agentId: "",
name: "Worker 1",
agentType: "teammate",
color: "green",
category: "quick",
model: "claude-sonnet-4-5",
prompt: "You are a helpful assistant",
planModeRequired: false,
joinedAt: "invalid-date",
cwd: "/tmp",
subscriptions: [],
backendType: "invalid" as const,
isActive: true,
}
// when
const result = TeamTeammateMemberSchema.safeParse(invalidTeammate)
// then
expect(result.success).toBe(false)
})
it("rejects reserved agentType for teammate schema", () => {
// given
const invalidTeammate = {
agentId: "worker@team",
name: "worker",
agentType: "team-lead",
category: "quick",
model: "native",
prompt: "do work",
color: "blue",
planModeRequired: false,
joinedAt: "2026-02-11T10:05:00Z",
cwd: "/tmp",
subscriptions: [],
backendType: "native",
isActive: false,
}
// when
const result = TeamTeammateMemberSchema.safeParse(invalidTeammate)
// then
expect(result.success).toBe(false)
})
})
describe("MessageTypeSchema", () => {
it("validates all 5 message types", () => {
// given
const types = ["message", "broadcast", "shutdown_request", "shutdown_response", "plan_approval_response"]
// when & then
types.forEach(type => {
const result = MessageTypeSchema.safeParse(type)
expect(result.success).toBe(true)
expect(result.data).toBe(type)
})
})
it("rejects invalid message type", () => {
// given
const invalidType = "invalid_type"
// when
const result = MessageTypeSchema.safeParse(invalidType)
// then
expect(result.success).toBe(false)
})
})
describe("InboxMessageSchema", () => {
it("validates a complete inbox message", () => {
// given
const message = {
id: "msg-123",
type: "message" as const,
sender: "agent-123",
recipient: "agent-456",
content: "Hello world",
summary: "Greeting",
timestamp: "2026-02-11T10:00:00Z",
read: false,
requestId: "req-123",
approve: true,
}
// when
const result = InboxMessageSchema.safeParse(message)
// then
expect(result.success).toBe(true)
})
it("validates message with optional fields missing", () => {
// given
const minimalMessage = {
id: "msg-123",
type: "broadcast" as const,
sender: "agent-123",
recipient: "agent-456",
content: "Hello world",
summary: "Greeting",
timestamp: "2026-02-11T10:00:00Z",
read: false,
}
// when
const result = InboxMessageSchema.safeParse(minimalMessage)
// then
expect(result.success).toBe(true)
})
it("rejects invalid inbox message", () => {
// given
const invalidMessage = {
id: "",
type: "invalid" as const,
sender: "",
recipient: "",
content: "",
summary: "",
timestamp: "invalid-date",
read: "not-boolean",
}
// when
const result = InboxMessageSchema.safeParse(invalidMessage)
// then
expect(result.success).toBe(false)
})
})
describe("TeamTaskSchema", () => {
it("validates a task object", () => {
// given
const task = {
id: "T-12345678-1234-1234-1234-123456789012",
subject: "Implement feature",
description: "Add new functionality",
status: "pending" as const,
activeForm: "Implementing feature",
blocks: [],
blockedBy: [],
owner: "agent-123",
metadata: { priority: "high" },
repoURL: "https://github.com/user/repo",
parentID: "T-parent",
threadID: "thread-123",
}
// when
const result = TeamTaskSchema.safeParse(task)
// then
expect(result.success).toBe(true)
})
it("rejects invalid task", () => {
// given
const invalidTask = {
id: "invalid-id",
subject: "",
description: "Add new functionality",
status: "invalid" as const,
activeForm: "Implementing feature",
blocks: [],
blockedBy: [],
}
// when
const result = TeamTaskSchema.safeParse(invalidTask)
// then
expect(result.success).toBe(false)
})
})
describe("TeamCreateInputSchema", () => {
it("validates create input with description", () => {
// given
const input = {
team_name: "my-team",
description: "A test team",
}
// when
const result = TeamCreateInputSchema.safeParse(input)
// then
expect(result.success).toBe(true)
})
it("validates create input without description", () => {
// given
const input = {
team_name: "my-team",
}
// when
const result = TeamCreateInputSchema.safeParse(input)
// then
expect(result.success).toBe(true)
})
it("rejects invalid create input", () => {
// given
const input = {
team_name: "invalid team name with spaces and special chars!",
}
// when
const result = TeamCreateInputSchema.safeParse(input)
// then
expect(result.success).toBe(false)
})
})
describe("TeamDeleteInputSchema", () => {
it("validates delete input", () => {
// given
const input = {
team_name: "my-team",
}
// when
const result = TeamDeleteInputSchema.safeParse(input)
// then
expect(result.success).toBe(true)
})
it("rejects invalid delete input", () => {
// given
const input = {
team_name: "",
}
// when
const result = TeamDeleteInputSchema.safeParse(input)
// then
expect(result.success).toBe(false)
})
})
describe("SendMessageInputSchema", () => {
it("validates message type input", () => {
// given
const input = {
team_name: "my-team",
type: "message" as const,
recipient: "agent-456",
content: "Hello world",
summary: "Greeting",
}
// when
const result = SendMessageInputSchema.safeParse(input)
// then
expect(result.success).toBe(true)
})
it("validates broadcast type input", () => {
// given
const input = {
team_name: "my-team",
type: "broadcast" as const,
content: "Team announcement",
summary: "Announcement",
}
// when
const result = SendMessageInputSchema.safeParse(input)
// then
expect(result.success).toBe(true)
})
it("validates shutdown_request type input", () => {
// given
const input = {
team_name: "my-team",
type: "shutdown_request" as const,
recipient: "agent-456",
content: "Please shutdown",
summary: "Shutdown request",
}
// when
const result = SendMessageInputSchema.safeParse(input)
// then
expect(result.success).toBe(true)
})
it("validates shutdown_response type input", () => {
// given
const input = {
team_name: "my-team",
type: "shutdown_response" as const,
request_id: "req-123",
approve: true,
}
// when
const result = SendMessageInputSchema.safeParse(input)
// then
expect(result.success).toBe(true)
})
it("validates plan_approval_response type input", () => {
// given
const input = {
team_name: "my-team",
type: "plan_approval_response" as const,
request_id: "req-456",
approve: false,
}
// when
const result = SendMessageInputSchema.safeParse(input)
// then
expect(result.success).toBe(true)
})
it("rejects message type without recipient", () => {
// given
const input = {
team_name: "my-team",
type: "message" as const,
content: "Hello world",
summary: "Greeting",
}
// when
const result = SendMessageInputSchema.safeParse(input)
// then
expect(result.success).toBe(false)
})
it("rejects shutdown_response without request_id", () => {
// given
const input = {
team_name: "my-team",
type: "shutdown_response" as const,
approve: true,
}
// when
const result = SendMessageInputSchema.safeParse(input)
// then
expect(result.success).toBe(false)
})
it("rejects invalid team_name", () => {
// given
const input = {
team_name: "invalid team name",
type: "broadcast" as const,
content: "Hello",
summary: "Greeting",
}
// when
const result = SendMessageInputSchema.safeParse(input)
// then
expect(result.success).toBe(false)
})
})
describe("ReadInboxInputSchema", () => {
it("validates read inbox input", () => {
// given
const input = {
team_name: "my-team",
agent_name: "worker-1",
unread_only: true,
mark_as_read: false,
}
// when
const result = ReadInboxInputSchema.safeParse(input)
// then
expect(result.success).toBe(true)
})
it("validates minimal read inbox input", () => {
// given
const input = {
team_name: "my-team",
agent_name: "worker-1",
}
// when
const result = ReadInboxInputSchema.safeParse(input)
// then
expect(result.success).toBe(true)
})
it("rejects invalid read inbox input", () => {
// given
const input = {
team_name: "",
agent_name: "",
}
// when
const result = ReadInboxInputSchema.safeParse(input)
// then
expect(result.success).toBe(false)
})
})
describe("ReadConfigInputSchema", () => {
it("validates read config input", () => {
// given
const input = {
team_name: "my-team",
}
// when
const result = ReadConfigInputSchema.safeParse(input)
// then
expect(result.success).toBe(true)
})
it("rejects invalid read config input", () => {
// given
const input = {
team_name: "",
}
// when
const result = ReadConfigInputSchema.safeParse(input)
// then
expect(result.success).toBe(false)
})
})
describe("TeamSpawnInputSchema", () => {
it("validates spawn input", () => {
// given
const input = {
team_name: "my-team",
name: "worker-1",
category: "quick",
prompt: "You are a helpful assistant",
}
// when
const result = TeamSpawnInputSchema.safeParse(input)
// then
expect(result.success).toBe(true)
})
it("rejects invalid spawn input", () => {
// given
const input = {
team_name: "invalid team",
name: "",
category: "quick",
prompt: "You are a helpful assistant",
}
// when
const result = TeamSpawnInputSchema.safeParse(input)
// then
expect(result.success).toBe(false)
})
})
describe("ForceKillTeammateInputSchema", () => {
it("validates force kill input", () => {
// given
const input = {
team_name: "my-team",
teammate_name: "worker-1",
}
// when
const result = ForceKillTeammateInputSchema.safeParse(input)
// then
expect(result.success).toBe(true)
})
it("rejects invalid force kill input", () => {
// given
const input = {
team_name: "",
teammate_name: "",
}
// when
const result = ForceKillTeammateInputSchema.safeParse(input)
// then
expect(result.success).toBe(false)
})
})
describe("ProcessShutdownApprovedInputSchema", () => {
it("validates shutdown approved input", () => {
// given
const input = {
team_name: "my-team",
teammate_name: "worker-1",
}
// when
const result = ProcessShutdownApprovedInputSchema.safeParse(input)
// then
expect(result.success).toBe(true)
})
it("rejects invalid shutdown approved input", () => {
// given
const input = {
team_name: "",
teammate_name: "",
}
// when
const result = ProcessShutdownApprovedInputSchema.safeParse(input)
// then
expect(result.success).toBe(false)
})
})

Some files were not shown because too many files have changed in this diff Show More