Compare commits

..

75 Commits

Author SHA1 Message Date
github-actions[bot]
8c0354225c release: v3.5.6 2026-02-16 07:24:09 +00:00
YeonGyu-Kim
9ba933743a fix: update prometheus prompt test to match compressed plan template wording 2026-02-16 16:21:14 +09:00
YeonGyu-Kim
cb4a165c76 Merge pull request #1882 from code-yeongyu/fix/resume-completion-timer-cleanup
fix: cancel completion timer on resume and prevent silent notification drop
2026-02-16 16:09:02 +09:00
YeonGyu-Kim
d3574a392f fix: cancel completion timer on resume and prevent silent notification drop 2026-02-16 16:06:36 +09:00
YeonGyu-Kim
0ef682965f fix: detect interrupted/error/cancelled status in unstable-agent-task polling loop
The polling loop in executeUnstableAgentTask only checked session status
and message stability, never checking if the background task itself had
been interrupted. This caused the tool call to hang until MAX_POLL_TIME_MS
(10 minutes) when a task was interrupted by prompt errors.

Add manager.getTask() check at each poll iteration to break immediately
on terminal statuses (interrupt, error, cancelled), returning a clear
failure message instead of hanging.
2026-02-16 15:56:52 +09:00
YeonGyu-Kim
dd11d5df1b refactor: compress plan template while recovering lost specificity guidelines
Reduce plan-template from 541 to 335 lines by removing redundant verbose
examples while recovering 3 lost context items: tool-type mapping table in
QA Policy, scenario specificity requirements (selectors/data/assertions/
timing/negative) in TODO template, and structured output format hints for
each Final Verification agent.
2026-02-16 15:46:00 +09:00
YeonGyu-Kim
130aaaf910 enhance: enforce mandatory per-task QA scenarios and add Final Verification Wave
Strengthen TODO template to make QA scenarios non-optional with explicit
rejection warning. Add Final Verification Wave with 4 parallel review
agents: oracle (plan compliance audit), unspecified-high (code quality),
unspecified-high (real manual QA), deep (scope fidelity check) — each
with detailed verification steps and structured output format.
2026-02-16 15:46:00 +09:00
YeonGyu-Kim
7e6982c8d8 Merge pull request #1878 from code-yeongyu/fix/1806-todo-enforcer-cooldown
fix: apply cooldown on injection failure and add max retry limit (#1806)
2026-02-16 15:42:24 +09:00
YeonGyu-Kim
2a4009e692 fix: add post-max-failure recovery window for todo continuation 2026-02-16 15:27:00 +09:00
YeonGyu-Kim
2b7ef43619 Merge pull request #1879 from code-yeongyu/fix/cli-installer-provider-config-1876
fix: run auth plugins and provider config for all providers, not just gemini
2026-02-16 15:26:55 +09:00
YeonGyu-Kim
5c9ef7bb1c fix: run auth plugins and provider config for all providers, not just gemini
Closes #1876
2026-02-16 15:23:22 +09:00
YeonGyu-Kim
67efe2d7af test: verify provider setup runs for openai/copilot without gemini 2026-02-16 15:23:22 +09:00
YeonGyu-Kim
abfab1a78a enhance: calibrate Prometheus plan granularity to 5-8 parallel tasks per wave
Add Maximum Parallelism Principle as a top-level constraint and replace
small-scale plan template examples (6 tasks, 3 waves) with production-scale
examples (24 tasks, 4 waves, max 7 concurrent) to steer the model toward
generating fine-grained, dependency-minimized plans by default.
2026-02-16 15:14:25 +09:00
YeonGyu-Kim
24ea3627ad Merge pull request #1877 from code-yeongyu/fix/1752-compaction-race
fix: cancel pending compaction timer on session.idle and add error logging (#1752)
2026-02-16 15:11:30 +09:00
YeonGyu-Kim
c2f22cd6e5 fix: apply cooldown on injection failure and cap retries 2026-02-16 15:00:41 +09:00
YeonGyu-Kim
6a90182503 fix: prevent duplicate compaction race and log preemptive failures 2026-02-16 14:58:59 +09:00
sisyphus-dev-ai
1509c897fc chore: changes by sisyphus-dev-ai 2026-02-16 05:09:17 +00:00
YeonGyu-Kim
dd91a7d990 Merge pull request #1874 from code-yeongyu/fix/toast-manager-ghost-entries
fix: add toast cleanup to all BackgroundManager task removal paths
2026-02-16 13:54:01 +09:00
YeonGyu-Kim
a9dd6d2ce8 Merge pull request #1873 from code-yeongyu/fix/first-message-variant-override
fix: preserve user-selected variant on first message instead of overriding with fallback chain default
2026-02-16 13:51:38 +09:00
YeonGyu-Kim
33d290b346 fix: add toast cleanup to all BackgroundManager task removal paths
TaskToastManager entries were never removed when tasks completed via
error, session deletion, stale pruning, or cancelled with
skipNotification. Ghost entries accumulated indefinitely, causing the
'Queued (N)' count in toast messages to grow without bound.

Added toastManager.removeTask() calls to all 4 missing cleanup paths:
- session.error handler
- session.deleted handler
- cancelTask with skipNotification
- pruneStaleTasksAndNotifications

Closes #1866
2026-02-16 13:50:57 +09:00
YeonGyu-Kim
7108d244d1 fix: preserve user-selected variant on first message instead of overriding with fallback chain default
First message variant gate was unconditionally overwriting message.variant
with the fallback chain value (e.g. 'medium' for Hephaestus), ignoring
any variant the user had already selected via OpenCode UI.

Now checks message.variant === undefined before applying the resolved
variant, matching the behavior already used for subsequent messages.

Closes #1861
2026-02-16 13:44:54 +09:00
github-actions[bot]
418e0e9f76 @dankochetov has signed the CLA in code-yeongyu/oh-my-opencode#1870 2026-02-15 23:17:14 +00:00
github-actions[bot]
b963571642 @Decrabbityyy has signed the CLA in code-yeongyu/oh-my-opencode#1864 2026-02-15 15:07:23 +00:00
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
144 changed files with 3775 additions and 8930 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.5",
"oh-my-opencode-darwin-x64": "3.5.5",
"oh-my-opencode-linux-arm64": "3.5.5",
"oh-my-opencode-linux-arm64-musl": "3.5.5",
"oh-my-opencode-linux-x64": "3.5.5",
"oh-my-opencode-linux-x64-musl": "3.5.5",
"oh-my-opencode-windows-x64": "3.5.5",
},
},
},
@@ -226,19 +226,19 @@
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.5.2", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-oIS3lB2F9/N+3mF5wCKk6/EPVSz516XWN+mNdquSSeddw+xqMxGdhKY6K/XeYbHJzeN2Z8IOikNEJ6psR2/a8g=="],
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.5.5", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-XtcCQ8/iVT6T1B58y0N1oMgOK4beTW8DW98b/ITnINb7b3hNSv5754Af/2Rx67BV0iE0ezC6uXaqz45C7ru1rw=="],
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.5.2", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-OAdXo4ZCCYO4kRWtnyz3tdmaGYPUB3WcXimXAxp+/sEZxAnh7n1RQkpLn6UxWX4AIAdRT9dfrOfRic6VoCYv2g=="],
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.5.5", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-ReSDqU6jihh7lpGNmEt3REzc5bOcyfv3cMHitpecKq0wRrJoTBI+dgNPk90BLjHobGbhAm0TE8VZ9tqTkivnIQ=="],
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.5.2", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-5XXNMFhp1VsyrGNRBoXcOyoaUeVkbrWkBRPDGZfpiq+kRXH3aaSWdR5G7Pl/TadOQv9Bl8/8YaxsuHRTFT1aXw=="],
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.5.5", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Zs/ETIxwcWBvw+jdlo8t+3+92oMMaXkFg1ZCuZrBRZOmtPFefdsH5/QEIe2TlNSjfoTwlA7cbpOD6oXgxRVrtg=="],
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.5.2", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-/woIpqvEI85MgJvEVnz4g5FBLeiQNK7srRsueIFPBmtTahh42HFleCDaIltOl/ndjsE5nCHacQVJHkC9W9/F3Q=="],
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.5.5", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-m9r4OW1XhGtm/SvHM3kzpS4pEiI2eIh5Tj+j5hpMW3wu+AqE3F1XGUpu8RgvIpupFo8beimJWDYQujqokReQqg=="],
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.5.2", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-vTL2A+6zzGhi+m7sC8peLDq5OAp2dRR0UEb4RbZAOHtlEruF7qFEmcK3ccWxwc3+Z3G/ITfwn5VNa72ZS4pNTg=="],
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.5.5", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-N6ysF5Pr2C1dyC5Dftzp05RJODgL+EYCWcOV59/UCV152cINlOhg80804o+6XTKV/taOAaboYaQwsBKiCs/BNQ=="],
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.5.2", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-bOAA55snLsK2QB00IkQy8le0Oqh/GJ7pxEHtm1oUezlQrW/nX5SS/hJ7dPHMmOd9FoiqnqyqWZxNkLmFoG463A=="],
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.5.5", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-MOxW1FMTJT3Ze/U2fDedcZUYTFaA9PaKIiqtsBIHOSb+fFgdo51RIuUlKCELN/g9I9dYhw0yP2n9tBMBG6feSg=="],
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.5.2", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-fnHiAPYglw3unPckmQBoCT6+VqjSWCE3S3J551mRo0ZFrxuEP2ZKyHZeFMMOtKwDepCvmKgd1W040+KmuVUXOA=="],
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.5.5", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-dWRtPyIdMFQIw1BwVO4PbGqoo0UWs7NES+YJC7BLGv0YnWN7Q2tatmOviSeSgMELeMsWSbDNisEB79jsfShXjA=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1471,6 +1471,54 @@
"created_at": "2026-02-14T04:15:19Z",
"repoId": 1108837393,
"pullRequestNo": 1827
},
{
"name": "morphaxl",
"id": 57144942,
"comment_id": 3872741516,
"created_at": "2026-02-09T16:21:56Z",
"repoId": 1108837393,
"pullRequestNo": 1699
},
{
"name": "morphaxl",
"id": 57144942,
"comment_id": 3872742242,
"created_at": "2026-02-09T16:22:04Z",
"repoId": 1108837393,
"pullRequestNo": 1699
},
{
"name": "liu-qingyuan",
"id": 57737268,
"comment_id": 3902402078,
"created_at": "2026-02-14T19:39:58Z",
"repoId": 1108837393,
"pullRequestNo": 1844
},
{
"name": "iyoda",
"id": 31020,
"comment_id": 3902426789,
"created_at": "2026-02-14T19:58:19Z",
"repoId": 1108837393,
"pullRequestNo": 1845
},
{
"name": "Decrabbityyy",
"id": 99632363,
"comment_id": 3904649522,
"created_at": "2026-02-15T15:07:11Z",
"repoId": 1108837393,
"pullRequestNo": 1864
},
{
"name": "dankochetov",
"id": 33990502,
"comment_id": 3905398332,
"created_at": "2026-02-15T23:17:05Z",
"repoId": 1108837393,
"pullRequestNo": 1870
}
]
}

View File

@@ -66,7 +66,7 @@ describe("PROMETHEUS_SYSTEM_PROMPT zero human intervention", () => {
expect(lowerPrompt).toContain("preconditions")
expect(lowerPrompt).toContain("failure indicators")
expect(lowerPrompt).toContain("evidence")
expect(lowerPrompt).toMatch(/negative scenario/)
expect(prompt).toMatch(/negative/i)
})
test("should require QA scenario adequacy in self-review checklist", () => {

View File

@@ -129,7 +129,21 @@ Your ONLY valid output locations are \`.sisyphus/plans/*.md\` and \`.sisyphus/dr
Example: \`.sisyphus/plans/auth-refactor.md\`
### 5. SINGLE PLAN MANDATE (CRITICAL)
### 5. MAXIMUM PARALLELISM PRINCIPLE (NON-NEGOTIABLE)
Your plans MUST maximize parallel execution. This is a core planning quality metric.
**Granularity Rule**: One task = one module/concern = 1-3 files.
If a task touches 4+ files or 2+ unrelated concerns, SPLIT IT.
**Parallelism Target**: Aim for 5-8 tasks per wave.
If any wave has fewer than 3 tasks (except the final integration), you under-split.
**Dependency Minimization**: Structure tasks so shared dependencies
(types, interfaces, configs) are extracted as early Wave-1 tasks,
unblocking maximum parallelism in subsequent waves.
### 6. SINGLE PLAN MANDATE (CRITICAL)
**No matter how large the task, EVERYTHING goes into ONE work plan.**
**NEVER:**
@@ -152,7 +166,7 @@ Example: \`.sisyphus/plans/auth-refactor.md\`
**The plan can have 50+ TODOs. That's OK. ONE PLAN.**
### 5.1 SINGLE ATOMIC WRITE (CRITICAL - Prevents Content Loss)
### 6.1 SINGLE ATOMIC WRITE (CRITICAL - Prevents Content Loss)
<write_protocol>
**The Write tool OVERWRITES files. It does NOT append.**
@@ -188,7 +202,7 @@ Example: \`.sisyphus/plans/auth-refactor.md\`
- [ ] File already exists with my content? → Use Edit to append, NOT Write
</write_protocol>
### 6. DRAFT AS WORKING MEMORY (MANDATORY)
### 7. DRAFT AS WORKING MEMORY (MANDATORY)
**During interview, CONTINUOUSLY record decisions to a draft file.**
**Draft Location**: \`.sisyphus/drafts/{name}.md\`

View File

@@ -70,108 +70,25 @@ Generate plan to: \`.sisyphus/plans/{name}.md\`
## Verification Strategy (MANDATORY)
> **UNIVERSAL RULE: ZERO HUMAN INTERVENTION**
>
> ALL tasks in this plan MUST be verifiable WITHOUT any human action.
> This is NOT conditional — it applies to EVERY task, regardless of test strategy.
>
> **FORBIDDEN** — acceptance criteria that require:
> - "User manually tests..." / "사용자가 직접 테스트..."
> - "User visually confirms..." / "사용자가 눈으로 확인..."
> - "User interacts with..." / "사용자가 직접 조작..."
> - "Ask user to verify..." / "사용자에게 확인 요청..."
> - ANY step where a human must perform an action
>
> **ALL verification is executed by the agent** using tools (Playwright, interactive_bash, curl, etc.). No exceptions.
> **ZERO HUMAN INTERVENTION** — ALL verification is agent-executed. No exceptions.
> Acceptance criteria requiring "user manually tests/confirms" are FORBIDDEN.
### Test Decision
- **Infrastructure exists**: [YES/NO]
- **Automated tests**: [TDD / Tests-after / None]
- **Framework**: [bun test / vitest / jest / pytest / none]
- **If TDD**: Each task follows RED (failing test) → GREEN (minimal impl) → REFACTOR
### If TDD Enabled
### QA Policy
Every task MUST include agent-executed QA scenarios (see TODO template below).
Evidence saved to \`.sisyphus/evidence/task-{N}-{scenario-slug}.{ext}\`.
Each TODO follows RED-GREEN-REFACTOR:
**Task Structure:**
1. **RED**: Write failing test first
- Test file: \`[path].test.ts\`
- Test command: \`bun test [file]\`
- Expected: FAIL (test exists, implementation doesn't)
2. **GREEN**: Implement minimum code to pass
- Command: \`bun test [file]\`
- Expected: PASS
3. **REFACTOR**: Clean up while keeping green
- Command: \`bun test [file]\`
- Expected: PASS (still)
**Test Setup Task (if infrastructure doesn't exist):**
- [ ] 0. Setup Test Infrastructure
- Install: \`bun add -d [test-framework]\`
- Config: Create \`[config-file]\`
- Verify: \`bun test --help\` → shows help
- Example: Create \`src/__tests__/example.test.ts\`
- Verify: \`bun test\` → 1 test passes
### Agent-Executed QA Scenarios (MANDATORY — ALL tasks)
> Whether TDD is enabled or not, EVERY task MUST include Agent-Executed QA Scenarios.
> - **With TDD**: QA scenarios complement unit tests at integration/E2E level
> - **Without TDD**: QA scenarios are the PRIMARY verification method
>
> These describe how the executing agent DIRECTLY verifies the deliverable
> by running it — opening browsers, executing commands, sending API requests.
> The agent performs what a human tester would do, but automated via tools.
**Verification Tool by Deliverable Type:**
| Type | Tool | How Agent Verifies |
|------|------|-------------------|
| **Frontend/UI** | Playwright (playwright skill) | Navigate, interact, assert DOM, screenshot |
| **TUI/CLI** | interactive_bash (tmux) | Run command, send keystrokes, validate output |
| **API/Backend** | Bash (curl/httpie) | Send requests, parse responses, assert fields |
| **Library/Module** | Bash (bun/node REPL) | Import, call functions, compare output |
| **Config/Infra** | Bash (shell commands) | Apply config, run state checks, validate |
**Each Scenario MUST Follow This Format:**
\`\`\`
Scenario: [Descriptive name — what user action/flow is being verified]
Tool: [Playwright / interactive_bash / Bash]
Preconditions: [What must be true before this scenario runs]
Steps:
1. [Exact action with specific selector/command/endpoint]
2. [Next action with expected intermediate state]
3. [Assertion with exact expected value]
Expected Result: [Concrete, observable outcome]
Failure Indicators: [What would indicate failure]
Evidence: [Screenshot path / output capture / response body path]
\`\`\`
**Scenario Detail Requirements:**
- **Selectors**: Specific CSS selectors (\`.login-button\`, not "the login button")
- **Data**: Concrete test data (\`"test@example.com"\`, not \`"[email]"\`)
- **Assertions**: Exact values (\`text contains "Welcome back"\`, not "verify it works")
- **Timing**: Include wait conditions where relevant (\`Wait for .dashboard (timeout: 10s)\`)
- **Negative Scenarios**: At least ONE failure/error scenario per feature
- **Evidence Paths**: Specific file paths (\`.sisyphus/evidence/task-N-scenario-name.png\`)
**Anti-patterns (NEVER write scenarios like this):**
- ❌ "Verify the login page works correctly"
- ❌ "Check that the API returns the right data"
- ❌ "Test the form validation"
- ❌ "User opens browser and confirms..."
**Write scenarios like this instead:**
- ✅ \`Navigate to /login → Fill input[name="email"] with "test@example.com" → Fill input[name="password"] with "Pass123!" → Click button[type="submit"] → Wait for /dashboard → Assert h1 contains "Welcome"\`
- ✅ \`POST /api/users {"name":"Test","email":"new@test.com"} → Assert status 201 → Assert response.id is UUID → GET /api/users/{id} → Assert name equals "Test"\`
- ✅ \`Run ./cli --config test.yaml → Wait for "Loaded" in stdout → Send "q" → Assert exit code 0 → Assert stdout contains "Goodbye"\`
**Evidence Requirements:**
- Screenshots: \`.sisyphus/evidence/\` for all UI verifications
- Terminal output: Captured for CLI/TUI verifications
- Response bodies: Saved for API verifications
- All evidence referenced by specific file path in acceptance criteria
| Deliverable Type | Verification Tool | Method |
|------------------|-------------------|--------|
| Frontend/UI | Playwright (playwright skill) | Navigate, interact, assert DOM, screenshot |
| TUI/CLI | interactive_bash (tmux) | Run command, send keystrokes, validate output |
| API/Backend | Bash (curl) | Send requests, assert status + response fields |
| Library/Module | Bash (bun/node REPL) | Import, call functions, compare output |
---
@@ -181,49 +98,82 @@ Scenario: [Descriptive name — what user action/flow is being verified]
> Maximize throughput by grouping independent tasks into parallel waves.
> Each wave completes before the next begins.
> Target: 5-8 tasks per wave. Fewer than 3 per wave (except final) = under-splitting.
\`\`\`
Wave 1 (Start Immediately):
├── Task 1: [no dependencies]
── Task 5: [no dependencies]
Wave 1 (Start Immediately — foundation + scaffolding):
├── Task 1: Project scaffolding + config [quick]
── Task 2: Design system tokens [quick]
├── Task 3: Type definitions [quick]
├── Task 4: Schema definitions [quick]
├── Task 5: Storage interface + in-memory impl [quick]
├── Task 6: Auth middleware [quick]
└── Task 7: Client module [quick]
Wave 2 (After Wave 1):
├── Task 2: [depends: 1]
├── Task 3: [depends: 1]
── Task 6: [depends: 5]
Wave 2 (After Wave 1 — core modules, MAX PARALLEL):
├── Task 8: Core business logic (depends: 3, 5, 7) [deep]
├── Task 9: API endpoints (depends: 4, 5) [unspecified-high]
── Task 10: Secondary storage impl (depends: 5) [unspecified-high]
├── Task 11: Retry/fallback logic (depends: 8) [deep]
├── Task 12: UI layout + navigation (depends: 2) [visual-engineering]
├── Task 13: API client + hooks (depends: 4) [quick]
└── Task 14: Telemetry middleware (depends: 5, 10) [unspecified-high]
Wave 3 (After Wave 2):
── Task 4: [depends: 2, 3]
Wave 3 (After Wave 2 — integration + UI):
── Task 15: Main route combining modules (depends: 6, 11, 14) [deep]
├── Task 16: UI data visualization (depends: 12, 13) [visual-engineering]
├── Task 17: Deployment config A (depends: 15) [quick]
├── Task 18: Deployment config B (depends: 15) [quick]
├── Task 19: Deployment config C (depends: 15) [quick]
└── Task 20: UI request log + build (depends: 16) [visual-engineering]
Critical Path: Task 1 → Task 2 → Task 4
Parallel Speedup: ~40% faster than sequential
Wave 4 (After Wave 3 — verification):
├── Task 21: Integration tests (depends: 15) [deep]
├── Task 22: UI QA - Playwright (depends: 20) [unspecified-high]
├── Task 23: E2E QA (depends: 21) [deep]
└── Task 24: Git cleanup + tagging (depends: 21) [git]
Wave FINAL (After ALL tasks — independent review, 4 parallel):
├── Task F1: Plan compliance audit (oracle)
├── Task F2: Code quality review (unspecified-high)
├── Task F3: Real manual QA (unspecified-high)
└── Task F4: Scope fidelity check (deep)
Critical Path: Task 1 → Task 5 → Task 8 → Task 11 → Task 15 → Task 21 → F1-F4
Parallel Speedup: ~70% faster than sequential
Max Concurrent: 7 (Waves 1 & 2)
\`\`\`
### Dependency Matrix
### Dependency Matrix (abbreviated — show ALL tasks in your generated plan)
| Task | Depends On | Blocks | Can Parallelize With |
|------|------------|--------|---------------------|
| 1 | None | 2, 3 | 5 |
| 2 | 1 | 4 | 3, 6 |
| 3 | 1 | 4 | 2, 6 |
| 4 | 2, 3 | None | None (final) |
| 5 | None | 6 | 1 |
| 6 | 5 | None | 2, 3 |
| Task | Depends On | Blocks | Wave |
|------|------------|--------|------|
| 1-7 | — | 8-14 | 1 |
| 8 | 3, 5, 7 | 11, 15 | 2 |
| 11 | 8 | 15 | 2 |
| 14 | 5, 10 | 15 | 2 |
| 15 | 6, 11, 14 | 17-19, 21 | 3 |
| 21 | 15 | 23, 24 | 4 |
> This is abbreviated for reference. YOUR generated plan must include the FULL matrix for ALL tasks.
### Agent Dispatch Summary
| Wave | Tasks | Recommended Agents |
|------|-------|-------------------|
| 1 | 1, 5 | task(category="...", load_skills=[...], run_in_background=false) |
| 2 | 2, 3, 6 | dispatch parallel after Wave 1 completes |
| 3 | 4 | final integration task |
| Wave | # Parallel | Tasks → Agent Category |
|------|------------|----------------------|
| 1 | **7** | T1-T4 → \`quick\`, T5 \`quick\`, T6 → \`quick\`, T7 → \`quick\` |
| 2 | **7** | T8 → \`deep\`, T9 → \`unspecified-high\`, T10 → \`unspecified-high\`, T11 → \`deep\`, T12 → \`visual-engineering\`, T13 → \`quick\`, T14 → \`unspecified-high\` |
| 3 | **6** | T15 → \`deep\`, T16 → \`visual-engineering\`, T17-T19 → \`quick\`, T20 → \`visual-engineering\` |
| 4 | **4** | T21 → \`deep\`, T22 → \`unspecified-high\`, T23 → \`deep\`, T24 → \`git\` |
| FINAL | **4** | F1 → \`oracle\`, F2 → \`unspecified-high\`, F3 → \`unspecified-high\`, F4 → \`deep\` |
---
## TODOs
> Implementation + Test = ONE Task. Never separate.
> EVERY task MUST have: Recommended Agent Profile + Parallelization info.
> EVERY task MUST have: Recommended Agent Profile + Parallelization info + QA Scenarios.
> **A task WITHOUT QA Scenarios is INCOMPLETE. No exceptions.**
- [ ] 1. [Task Title]
@@ -257,22 +207,15 @@ Parallel Speedup: ~40% faster than sequential
**Pattern References** (existing code to follow):
- \`src/services/auth.ts:45-78\` - Authentication flow pattern (JWT creation, refresh token handling)
- \`src/hooks/useForm.ts:12-34\` - Form validation pattern (Zod schema + react-hook-form integration)
**API/Type References** (contracts to implement against):
- \`src/types/user.ts:UserDTO\` - Response shape for user endpoints
- \`src/api/schema.ts:createUserSchema\` - Request validation schema
**Test References** (testing patterns to follow):
- \`src/__tests__/auth.test.ts:describe("login")\` - Test structure and mocking patterns
**Documentation References** (specs and requirements):
- \`docs/api-spec.md#authentication\` - API contract details
- \`ARCHITECTURE.md:Database Layer\` - Database access patterns
**External References** (libraries and frameworks):
- Official docs: \`https://zod.dev/?id=basic-usage\` - Zod validation syntax
- Example repo: \`github.com/example/project/src/auth\` - Reference implementation
**WHY Each Reference Matters** (explain the relevance):
- Don't just list files - explain what pattern/information the executor should extract
@@ -283,113 +226,60 @@ Parallel Speedup: ~40% faster than sequential
> **AGENT-EXECUTABLE VERIFICATION ONLY** — No human action permitted.
> Every criterion MUST be verifiable by running a command or using a tool.
> REPLACE all placeholders with actual values from task context.
**If TDD (tests enabled):**
- [ ] Test file created: src/auth/login.test.ts
- [ ] Test covers: successful login returns JWT token
- [ ] bun test src/auth/login.test.ts → PASS (3 tests, 0 failures)
**Agent-Executed QA Scenarios (MANDATORY — per-scenario, ultra-detailed):**
**QA Scenarios (MANDATORY — task is INCOMPLETE without these):**
> Write MULTIPLE named scenarios per task: happy path AND failure cases.
> Each scenario = exact tool + steps with real selectors/data + evidence path.
**Example — Frontend/UI (Playwright):**
> **This is NOT optional. A task without QA scenarios WILL BE REJECTED.**
>
> Write scenario tests that verify the ACTUAL BEHAVIOR of what you built.
> Minimum: 1 happy path + 1 failure/edge case per task.
> Each scenario = exact tool + exact steps + exact assertions + evidence path.
>
> **The executing agent MUST run these scenarios after implementation.**
> **The orchestrator WILL verify evidence files exist before marking task complete.**
\\\`\\\`\\\`
Scenario: Successful login redirects to dashboard
Tool: Playwright (playwright skill)
Preconditions: Dev server running on localhost:3000, test user exists
Scenario: [Happy path — what SHOULD work]
Tool: [Playwright / interactive_bash / Bash (curl)]
Preconditions: [Exact setup state]
Steps:
1. Navigate to: http://localhost:3000/login
2. Wait for: input[name="email"] visible (timeout: 5s)
3. Fill: input[name="email"] → "test@example.com"
4. Fill: input[name="password"] → "ValidPass123!"
5. Click: button[type="submit"]
6. Wait for: navigation to /dashboard (timeout: 10s)
7. Assert: h1 text contains "Welcome back"
8. Assert: cookie "session_token" exists
9. Screenshot: .sisyphus/evidence/task-1-login-success.png
Expected Result: Dashboard loads with welcome message
Evidence: .sisyphus/evidence/task-1-login-success.png
1. [Exact action — specific command/selector/endpoint, no vagueness]
2. [Next action — with expected intermediate state]
3. [Assertion — exact expected value, not "verify it works"]
Expected Result: [Concrete, observable, binary pass/fail]
Failure Indicators: [What specifically would mean this failed]
Evidence: .sisyphus/evidence/task-{N}-{scenario-slug}.{ext}
Scenario: Login fails with invalid credentials
Tool: Playwright (playwright skill)
Preconditions: Dev server running, no valid user with these credentials
Scenario: [Failure/edge case — what SHOULD fail gracefully]
Tool: [same format]
Preconditions: [Invalid input / missing dependency / error state]
Steps:
1. Navigate to: http://localhost:3000/login
2. Fill: input[name="email"] → "wrong@example.com"
3. Fill: input[name="password"] → "WrongPass"
4. Click: button[type="submit"]
5. Wait for: .error-message visible (timeout: 5s)
6. Assert: .error-message text contains "Invalid credentials"
7. Assert: URL is still /login (no redirect)
8. Screenshot: .sisyphus/evidence/task-1-login-failure.png
Expected Result: Error message shown, stays on login page
Evidence: .sisyphus/evidence/task-1-login-failure.png
1. [Trigger the error condition]
2. [Assert error is handled correctly]
Expected Result: [Graceful failure with correct error message/code]
Evidence: .sisyphus/evidence/task-{N}-{scenario-slug}-error.{ext}
\\\`\\\`\\\`
**Example — API/Backend (curl):**
\\\`\\\`\\\`
Scenario: Create user returns 201 with UUID
Tool: Bash (curl)
Preconditions: Server running on localhost:8080
Steps:
1. curl -s -w "\\n%{http_code}" -X POST http://localhost:8080/api/users \\
-H "Content-Type: application/json" \\
-d '{"email":"new@test.com","name":"Test User"}'
2. Assert: HTTP status is 201
3. Assert: response.id matches UUID format
4. GET /api/users/{returned-id} → Assert name equals "Test User"
Expected Result: User created and retrievable
Evidence: Response bodies captured
Scenario: Duplicate email returns 409
Tool: Bash (curl)
Preconditions: User with email "new@test.com" already exists
Steps:
1. Repeat POST with same email
2. Assert: HTTP status is 409
3. Assert: response.error contains "already exists"
Expected Result: Conflict error returned
Evidence: Response body captured
\\\`\\\`\\\`
**Example — TUI/CLI (interactive_bash):**
\\\`\\\`\\\`
Scenario: CLI loads config and displays menu
Tool: interactive_bash (tmux)
Preconditions: Binary built, test config at ./test.yaml
Steps:
1. tmux new-session: ./my-cli --config test.yaml
2. Wait for: "Configuration loaded" in output (timeout: 5s)
3. Assert: Menu items visible ("1. Create", "2. List", "3. Exit")
4. Send keys: "3" then Enter
5. Assert: "Goodbye" in output
6. Assert: Process exited with code 0
Expected Result: CLI starts, shows menu, exits cleanly
Evidence: Terminal output captured
Scenario: CLI handles missing config gracefully
Tool: interactive_bash (tmux)
Preconditions: No config file at ./nonexistent.yaml
Steps:
1. tmux new-session: ./my-cli --config nonexistent.yaml
2. Wait for: output (timeout: 3s)
3. Assert: stderr contains "Config file not found"
4. Assert: Process exited with code 1
Expected Result: Meaningful error, non-zero exit
Evidence: Error output captured
\\\`\\\`\\\`
> **Specificity requirements — every scenario MUST use:**
> - **Selectors**: Specific CSS selectors (\`.login-button\`, not "the login button")
> - **Data**: Concrete test data (\`"test@example.com"\`, not \`"[email]"\`)
> - **Assertions**: Exact values (\`text contains "Welcome back"\`, not "verify it works")
> - **Timing**: Wait conditions where relevant (\`timeout: 10s\`)
> - **Negative**: At least ONE failure/error scenario per task
>
> **Anti-patterns (your scenario is INVALID if it looks like this):**
> - ❌ "Verify it works correctly" — HOW? What does "correctly" mean?
> - ❌ "Check the API returns data" — WHAT data? What fields? What values?
> - ❌ "Test the component renders" — WHERE? What selector? What content?
> - ❌ Any scenario without an evidence path
**Evidence to Capture:**
- [ ] Screenshots in .sisyphus/evidence/ for UI scenarios
- [ ] Terminal output for CLI/TUI scenarios
- [ ] Response bodies for API scenarios
- [ ] Each evidence file named: task-{N}-{scenario-slug}.{ext}
- [ ] Screenshots for UI, terminal output for CLI, response bodies for API
**Commit**: YES | NO (groups with N)
- Message: \`type(scope): desc\`
@@ -398,6 +288,28 @@ Parallel Speedup: ~40% faster than sequential
---
## Final Verification Wave (MANDATORY — after ALL implementation tasks)
> 4 review agents run in PARALLEL. ALL must APPROVE. Rejection → fix → re-run.
- [ ] F1. **Plan Compliance Audit** — \`oracle\`
Read the plan end-to-end. For each "Must Have": verify implementation exists (read file, curl endpoint, run command). For each "Must NOT Have": search codebase for forbidden patterns — reject with file:line if found. Check evidence files exist in .sisyphus/evidence/. Compare deliverables against plan.
Output: \`Must Have [N/N] | Must NOT Have [N/N] | Tasks [N/N] | VERDICT: APPROVE/REJECT\`
- [ ] F2. **Code Quality Review** — \`unspecified-high\`
Run \`tsc --noEmit\` + linter + \`bun test\`. Review all changed files for: \`as any\`/\`@ts-ignore\`, empty catches, console.log in prod, commented-out code, unused imports. Check AI slop: excessive comments, over-abstraction, generic names (data/result/item/temp).
Output: \`Build [PASS/FAIL] | Lint [PASS/FAIL] | Tests [N pass/N fail] | Files [N clean/N issues] | VERDICT\`
- [ ] F3. **Real Manual QA** — \`unspecified-high\` (+ \`playwright\` skill if UI)
Start from clean state. Execute EVERY QA scenario from EVERY task — follow exact steps, capture evidence. Test cross-task integration (features working together, not isolation). Test edge cases: empty state, invalid input, rapid actions. Save to \`.sisyphus/evidence/final-qa/\`.
Output: \`Scenarios [N/N pass] | Integration [N/N] | Edge Cases [N tested] | VERDICT\`
- [ ] F4. **Scope Fidelity Check** — \`deep\`
For each task: read "What to do", read actual diff (git log/diff). Verify 1:1 — everything in spec was built (no missing), nothing beyond spec was built (no creep). Check "Must NOT do" compliance. Detect cross-task contamination: Task N touching Task M's files. Flag unaccounted changes.
Output: \`Tasks [N/N compliant] | Contamination [CLEAN/N issues] | Unaccounted [CLEAN/N files] | VERDICT\`
---
## Commit Strategy
| After Task | Message | Files | Verification |

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

@@ -0,0 +1,83 @@
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"
import * as configManager from "./config-manager"
import { runCliInstaller } from "./cli-installer"
import type { InstallArgs } from "./types"
describe("runCliInstaller", () => {
const mockConsoleLog = mock(() => {})
const mockConsoleError = mock(() => {})
const originalConsoleLog = console.log
const originalConsoleError = console.error
beforeEach(() => {
console.log = mockConsoleLog
console.error = mockConsoleError
mockConsoleLog.mockClear()
mockConsoleError.mockClear()
})
afterEach(() => {
console.log = originalConsoleLog
console.error = originalConsoleError
})
it("runs auth and provider setup steps when openai or copilot are enabled without gemini", async () => {
//#given
const addAuthPluginsSpy = spyOn(configManager, "addAuthPlugins").mockResolvedValue({
success: true,
configPath: "/tmp/opencode.jsonc",
})
const addProviderConfigSpy = spyOn(configManager, "addProviderConfig").mockReturnValue({
success: true,
configPath: "/tmp/opencode.jsonc",
})
const restoreSpies = [
addAuthPluginsSpy,
addProviderConfigSpy,
spyOn(configManager, "detectCurrentConfig").mockReturnValue({
isInstalled: false,
hasClaude: false,
isMax20: false,
hasOpenAI: false,
hasGemini: false,
hasCopilot: false,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
}),
spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true),
spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.0.200"),
spyOn(configManager, "addPluginToOpenCodeConfig").mockResolvedValue({
success: true,
configPath: "/tmp/opencode.jsonc",
}),
spyOn(configManager, "writeOmoConfig").mockReturnValue({
success: true,
configPath: "/tmp/oh-my-opencode.jsonc",
}),
]
const args: InstallArgs = {
tui: false,
claude: "no",
openai: "yes",
gemini: "no",
copilot: "yes",
opencodeZen: "no",
zaiCodingPlan: "no",
kimiForCoding: "no",
}
//#when
const result = await runCliInstaller(args, "3.4.0")
//#then
expect(result).toBe(0)
expect(addAuthPluginsSpy).toHaveBeenCalledTimes(1)
expect(addProviderConfigSpy).toHaveBeenCalledTimes(1)
for (const spy of restoreSpies) {
spy.mockRestore()
}
})
})

View File

@@ -77,7 +77,9 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi
`Plugin ${isUpdate ? "verified" : "added"} ${SYMBOLS.arrow} ${color.dim(pluginResult.configPath)}`,
)
if (config.hasGemini) {
const needsProviderSetup = config.hasGemini || config.hasOpenAI || config.hasCopilot
if (needsProviderSetup) {
printStep(step++, totalSteps, "Adding auth plugins...")
const authResult = await addAuthPlugins(config)
if (!authResult.success) {

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

@@ -6,6 +6,7 @@ import type { BackgroundTask, ResumeInput } from "./types"
import { MIN_IDLE_TIME_MS } from "./constants"
import { BackgroundManager } from "./manager"
import { ConcurrencyManager } from "./concurrency"
import { initTaskToastManager, _resetTaskToastManagerForTesting } from "../task-toast-manager/manager"
const TASK_TTL_MS = 30 * 60 * 1000
@@ -190,6 +191,10 @@ function getPendingByParent(manager: BackgroundManager): Map<string, Set<string>
return (manager as unknown as { pendingByParent: Map<string, Set<string>> }).pendingByParent
}
function getCompletionTimers(manager: BackgroundManager): Map<string, ReturnType<typeof setTimeout>> {
return (manager as unknown as { completionTimers: Map<string, ReturnType<typeof setTimeout>> }).completionTimers
}
function getQueuesByKey(
manager: BackgroundManager
): Map<string, Array<{ task: BackgroundTask; input: import("./types").LaunchInput }>> {
@@ -215,6 +220,23 @@ function stubNotifyParentSession(manager: BackgroundManager): void {
;(manager as unknown as { notifyParentSession: () => Promise<void> }).notifyParentSession = async () => {}
}
function createToastRemoveTaskTracker(): { removeTaskCalls: string[]; resetToastManager: () => void } {
_resetTaskToastManagerForTesting()
const toastManager = initTaskToastManager({
tui: { showToast: async () => {} },
} as unknown as PluginInput["client"])
const removeTaskCalls: string[] = []
const originalRemoveTask = toastManager.removeTask.bind(toastManager)
toastManager.removeTask = (taskId: string): void => {
removeTaskCalls.push(taskId)
originalRemoveTask(taskId)
}
return {
removeTaskCalls,
resetToastManager: _resetTaskToastManagerForTesting,
}
}
function getCleanupSignals(): Array<NodeJS.Signals | "beforeExit" | "exit"> {
const signals: Array<NodeJS.Signals | "beforeExit" | "exit"> = ["SIGINT", "SIGTERM", "beforeExit", "exit"]
if (process.platform === "win32") {
@@ -894,7 +916,7 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () =>
})
describe("BackgroundManager.notifyParentSession - aborted parent", () => {
test("should skip notification when parent session is aborted", async () => {
test("should fall back and still notify when parent session messages are aborted", async () => {
//#given
let promptCalled = false
const promptMock = async () => {
@@ -933,7 +955,7 @@ describe("BackgroundManager.notifyParentSession - aborted parent", () => {
.notifyParentSession(task)
//#then
expect(promptCalled).toBe(false)
expect(promptCalled).toBe(true)
manager.shutdown()
})
@@ -1770,6 +1792,32 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
const pendingSet = pendingByParent.get(task.parentSessionID)
expect(pendingSet?.has(task.id) ?? false).toBe(false)
})
test("should remove task from toast manager when notification is skipped", async () => {
//#given
const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker()
const manager = createBackgroundManager()
const task = createMockTask({
id: "task-cancel-skip-notification",
sessionID: "session-cancel-skip-notification",
parentSessionID: "parent-cancel-skip-notification",
status: "running",
})
getTaskMap(manager).set(task.id, task)
//#when
const cancelled = await manager.cancelTask(task.id, {
source: "test",
skipNotification: true,
})
//#then
expect(cancelled).toBe(true)
expect(removeTaskCalls).toContain(task.id)
manager.shutdown()
resetToastManager()
})
})
describe("multiple keys process in parallel", () => {
@@ -2289,10 +2337,221 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
getTaskMap(manager).set(task.id, task)
await manager["checkAndInterruptStaleTasks"]()
await manager["checkAndInterruptStaleTasks"]()
expect(task.status).toBe("cancelled")
})
test("should NOT interrupt task when session is running, even with stale lastUpdate", async () => {
//#given
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
const task: BackgroundTask = {
id: "task-running-session",
sessionID: "session-running",
parentSessionID: "parent-rs",
parentMessageID: "msg-rs",
description: "Task with running session",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 300_000),
progress: {
toolCalls: 2,
lastUpdate: new Date(Date.now() - 300_000),
},
}
getTaskMap(manager).set(task.id, task)
//#when — session is actively running
await manager["checkAndInterruptStaleTasks"]({ "session-running": { type: "running" } })
//#then — task survives because session is running
expect(task.status).toBe("running")
})
test("should interrupt task when session is idle and lastUpdate exceeds stale timeout", async () => {
//#given
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
stubNotifyParentSession(manager)
const task: BackgroundTask = {
id: "task-idle-session",
sessionID: "session-idle",
parentSessionID: "parent-is",
parentMessageID: "msg-is",
description: "Task with idle session",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 300_000),
progress: {
toolCalls: 2,
lastUpdate: new Date(Date.now() - 300_000),
},
}
getTaskMap(manager).set(task.id, task)
//#when — session is idle
await manager["checkAndInterruptStaleTasks"]({ "session-idle": { type: "idle" } })
//#then — killed because session is idle with stale lastUpdate
expect(task.status).toBe("cancelled")
expect(task.error).toContain("Stale timeout")
})
test("should NOT interrupt running session even with very old lastUpdate (no safety net)", async () => {
//#given
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
const task: BackgroundTask = {
id: "task-long-running",
sessionID: "session-long",
parentSessionID: "parent-lr",
parentMessageID: "msg-lr",
description: "Long running task",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 900_000),
progress: {
toolCalls: 5,
lastUpdate: new Date(Date.now() - 900_000),
},
}
getTaskMap(manager).set(task.id, task)
//#when — session is running, lastUpdate 15min old
await manager["checkAndInterruptStaleTasks"]({ "session-long": { type: "running" } })
//#then — running sessions are NEVER stale-killed
expect(task.status).toBe("running")
})
test("should NOT interrupt running session with no progress (undefined lastUpdate)", async () => {
//#given — no progress at all, but session is running
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { messageStalenessTimeoutMs: 600_000 })
const task: BackgroundTask = {
id: "task-running-no-progress",
sessionID: "session-rnp",
parentSessionID: "parent-rnp",
parentMessageID: "msg-rnp",
description: "Running no progress",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 15 * 60 * 1000),
progress: undefined,
}
getTaskMap(manager).set(task.id, task)
//#when — session is running despite no progress
await manager["checkAndInterruptStaleTasks"]({ "session-rnp": { type: "running" } })
//#then — running sessions are NEVER killed
expect(task.status).toBe("running")
})
test("should interrupt task with no lastUpdate after messageStalenessTimeout", async () => {
//#given
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { messageStalenessTimeoutMs: 600_000 })
stubNotifyParentSession(manager)
const task: BackgroundTask = {
id: "task-no-update",
sessionID: "session-no-update",
parentSessionID: "parent-nu",
parentMessageID: "msg-nu",
description: "No update task",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 15 * 60 * 1000),
progress: undefined,
}
getTaskMap(manager).set(task.id, task)
//#when — no progress update for 15 minutes
await manager["checkAndInterruptStaleTasks"]({})
//#then — killed after messageStalenessTimeout
expect(task.status).toBe("cancelled")
expect(task.error).toContain("no activity")
})
test("should NOT interrupt task with no lastUpdate within messageStalenessTimeout", async () => {
//#given
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { messageStalenessTimeoutMs: 600_000 })
const task: BackgroundTask = {
id: "task-fresh-no-update",
sessionID: "session-fresh",
parentSessionID: "parent-fn",
parentMessageID: "msg-fn",
description: "Fresh no-update task",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 5 * 60 * 1000),
progress: undefined,
}
getTaskMap(manager).set(task.id, task)
//#when — only 5 min since start, within 10min timeout
await manager["checkAndInterruptStaleTasks"]({})
//#then — task survives
expect(task.status).toBe("running")
})
})
describe("BackgroundManager.shutdown session abort", () => {
@@ -2519,6 +2778,43 @@ describe("BackgroundManager.handleEvent - session.deleted cascade", () => {
manager.shutdown()
})
test("should remove tasks from toast manager when session is deleted", () => {
//#given
const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker()
const manager = createBackgroundManager()
const parentSessionID = "session-parent-toast"
const childTask = createMockTask({
id: "task-child-toast",
sessionID: "session-child-toast",
parentSessionID,
status: "running",
})
const grandchildTask = createMockTask({
id: "task-grandchild-toast",
sessionID: "session-grandchild-toast",
parentSessionID: "session-child-toast",
status: "pending",
startedAt: undefined,
queuedAt: new Date(),
})
const taskMap = getTaskMap(manager)
taskMap.set(childTask.id, childTask)
taskMap.set(grandchildTask.id, grandchildTask)
//#when
manager.handleEvent({
type: "session.deleted",
properties: { info: { id: parentSessionID } },
})
//#then
expect(removeTaskCalls).toContain(childTask.id)
expect(removeTaskCalls).toContain(grandchildTask.id)
manager.shutdown()
resetToastManager()
})
})
describe("BackgroundManager.handleEvent - session.error", () => {
@@ -2566,6 +2862,35 @@ describe("BackgroundManager.handleEvent - session.error", () => {
manager.shutdown()
})
test("removes errored task from toast manager", () => {
//#given
const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker()
const manager = createBackgroundManager()
const sessionID = "ses_error_toast"
const task = createMockTask({
id: "task-session-error-toast",
sessionID,
parentSessionID: "parent-session",
status: "running",
})
getTaskMap(manager).set(task.id, task)
//#when
manager.handleEvent({
type: "session.error",
properties: {
sessionID,
error: { name: "UnknownError", message: "boom" },
},
})
//#then
expect(removeTaskCalls).toContain(task.id)
manager.shutdown()
resetToastManager()
})
test("ignores session.error for non-running tasks", () => {
//#given
const manager = createBackgroundManager()
@@ -2711,13 +3036,32 @@ describe("BackgroundManager.pruneStaleTasksAndNotifications - removes pruned tas
manager.shutdown()
})
test("removes stale task from toast manager", () => {
//#given
const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker()
const manager = createBackgroundManager()
const staleTask = createMockTask({
id: "task-stale-toast",
sessionID: "session-stale-toast",
parentSessionID: "parent-session",
status: "running",
startedAt: new Date(Date.now() - 31 * 60 * 1000),
})
getTaskMap(manager).set(staleTask.id, staleTask)
//#when
pruneStaleTasksAndNotificationsForTest(manager)
//#then
expect(removeTaskCalls).toContain(staleTask.id)
manager.shutdown()
resetToastManager()
})
})
describe("BackgroundManager.completionTimers - Memory Leak Fix", () => {
function getCompletionTimers(manager: BackgroundManager): Map<string, ReturnType<typeof setTimeout>> {
return (manager as unknown as { completionTimers: Map<string, ReturnType<typeof setTimeout>> }).completionTimers
}
function setCompletionTimer(manager: BackgroundManager, taskId: string): void {
const completionTimers = getCompletionTimers(manager)
const timer = setTimeout(() => {
@@ -3202,4 +3546,134 @@ describe("BackgroundManager.handleEvent - non-tool event lastUpdate", () => {
//#then - task should still be running (text event refreshed lastUpdate)
expect(task.status).toBe("running")
})
test("should refresh lastUpdate on message.part.delta events (OpenCode >=1.2.0)", async () => {
//#given - a running task with stale lastUpdate
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
stubNotifyParentSession(manager)
const task: BackgroundTask = {
id: "task-delta-1",
sessionID: "session-delta-1",
parentSessionID: "parent-1",
parentMessageID: "msg-1",
description: "Reasoning task with delta events",
prompt: "Extended thinking",
agent: "oracle",
status: "running",
startedAt: new Date(Date.now() - 600_000),
progress: {
toolCalls: 0,
lastUpdate: new Date(Date.now() - 300_000),
},
}
getTaskMap(manager).set(task.id, task)
//#when - a message.part.delta event arrives (reasoning-delta or text-delta in OpenCode >=1.2.0)
manager.handleEvent({
type: "message.part.delta",
properties: { sessionID: "session-delta-1", field: "text", delta: "thinking..." },
})
await manager["checkAndInterruptStaleTasks"]()
//#then - task should still be running (delta event refreshed lastUpdate)
expect(task.status).toBe("running")
})
})
describe("BackgroundManager regression fixes - resume and aborted notification", () => {
test("should keep resumed task in memory after previous completion timer deadline", async () => {
//#given
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const task: BackgroundTask = {
id: "task-resume-timer-regression",
sessionID: "session-resume-timer-regression",
parentSessionID: "parent-session",
parentMessageID: "msg-1",
description: "resume timer regression",
prompt: "test",
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
concurrencyGroup: "explore",
}
getTaskMap(manager).set(task.id, task)
const completionTimers = getCompletionTimers(manager)
const timer = setTimeout(() => {
completionTimers.delete(task.id)
getTaskMap(manager).delete(task.id)
}, 25)
completionTimers.set(task.id, timer)
//#when
await manager.resume({
sessionId: "session-resume-timer-regression",
prompt: "resume task",
parentSessionID: "parent-session-2",
parentMessageID: "msg-2",
})
await new Promise((resolve) => setTimeout(resolve, 60))
//#then
expect(getTaskMap(manager).has(task.id)).toBe(true)
expect(completionTimers.has(task.id)).toBe(false)
manager.shutdown()
})
test("should start cleanup timer even when promptAsync aborts", async () => {
//#given
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => {
const error = new Error("User aborted")
error.name = "MessageAbortedError"
throw error
},
abort: async () => ({}),
messages: async () => ({ data: [] }),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const task: BackgroundTask = {
id: "task-aborted-cleanup-regression",
sessionID: "session-aborted-cleanup-regression",
parentSessionID: "parent-session",
parentMessageID: "msg-1",
description: "aborted prompt cleanup regression",
prompt: "test",
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
}
getTaskMap(manager).set(task.id, task)
getPendingByParent(manager).set(task.parentSessionID, new Set([task.id]))
//#when
await (manager as unknown as { notifyParentSession: (task: BackgroundTask) => Promise<void> }).notifyParentSession(task)
//#then
expect(getCompletionTimers(manager).has(task.id)).toBe(true)
manager.shutdown()
})
})

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) => {
@@ -521,6 +528,12 @@ export class BackgroundManager {
return existingTask
}
const completionTimer = this.completionTimers.get(existingTask.id)
if (completionTimer) {
clearTimeout(completionTimer)
this.completionTimers.delete(existingTask.id)
}
// Re-acquire concurrency using the persisted concurrency group
const concurrencyKey = existingTask.concurrencyGroup ?? existingTask.agent
await this.concurrencyManager.acquire(concurrencyKey)
@@ -535,6 +548,9 @@ export class BackgroundManager {
existingTask.parentMessageID = input.parentMessageID
existingTask.parentModel = input.parentModel
existingTask.parentAgent = input.parentAgent
if (input.parentTools) {
existingTask.parentTools = input.parentTools
}
// Reset startedAt on resume to prevent immediate completion
// The MIN_IDLE_TIME_MS check uses startedAt, so resumed tasks need fresh timing
existingTask.startedAt = new Date()
@@ -588,12 +604,16 @@ export class BackgroundManager {
agent: existingTask.agent,
...(resumeModel ? { model: resumeModel } : {}),
...(resumeVariant ? { variant: resumeVariant } : {}),
tools: {
...getAgentToolRestrictions(existingTask.agent),
task: false,
call_omo_agent: true,
question: false,
},
tools: (() => {
const tools = {
...getAgentToolRestrictions(existingTask.agent),
task: false,
call_omo_agent: true,
question: false,
}
setSessionTools(existingTask.sessionID!, tools)
return tools
})(),
parts: [{ type: "text", text: input.prompt }],
},
}).catch((error) => {
@@ -646,7 +666,7 @@ export class BackgroundManager {
handleEvent(event: Event): void {
const props = event.properties
if (event.type === "message.part.updated") {
if (event.type === "message.part.updated" || event.type === "message.part.delta") {
if (!props || typeof props !== "object" || !("sessionID" in props)) return
const partInfo = props as unknown as MessagePartInfo
const sessionID = partInfo?.sessionID
@@ -769,6 +789,10 @@ export class BackgroundManager {
this.cleanupPendingByParent(task)
this.tasks.delete(task.id)
this.clearNotificationsForTask(task.id)
const toastManager = getTaskToastManager()
if (toastManager) {
toastManager.removeTask(task.id)
}
if (task.sessionID) {
subagentSessions.delete(task.sessionID)
}
@@ -816,6 +840,10 @@ export class BackgroundManager {
this.cleanupPendingByParent(task)
this.tasks.delete(task.id)
this.clearNotificationsForTask(task.id)
const toastManager = getTaskToastManager()
if (toastManager) {
toastManager.removeTask(task.id)
}
if (task.sessionID) {
subagentSessions.delete(task.sessionID)
}
@@ -986,6 +1014,10 @@ export class BackgroundManager {
}
if (options?.skipNotification) {
const toastManager = getTaskToastManager()
if (toastManager) {
toastManager.removeTask(task.id)
}
log(`[background-agent] Task cancelled via ${source} (notification skipped):`, task.id)
return true
}
@@ -1225,11 +1257,10 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
}
} catch (error) {
if (this.isAbortedSessionError(error)) {
log("[background-agent] Parent session aborted, skipping notification:", {
log("[background-agent] Parent session aborted while loading messages; using messageDir fallback:", {
taskId: task.id,
parentSessionID: task.parentSessionID,
})
return
}
const messageDir = getMessageDir(task.parentSessionID)
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
@@ -1252,6 +1283,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
noReply: !allComplete,
...(agent !== undefined ? { agent } : {}),
...(model !== undefined ? { model } : {}),
...(task.parentTools ? { tools: task.parentTools } : {}),
parts: [{ type: "text", text: notification }],
},
})
@@ -1262,13 +1294,13 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
})
} catch (error) {
if (this.isAbortedSessionError(error)) {
log("[background-agent] Parent session aborted, skipping notification:", {
log("[background-agent] Parent session aborted while sending notification; continuing cleanup:", {
taskId: task.id,
parentSessionID: task.parentSessionID,
})
return
} else {
log("[background-agent] Failed to send notification:", error)
}
log("[background-agent] Failed to send notification:", error)
}
if (allComplete) {
@@ -1398,6 +1430,10 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
}
}
this.clearNotificationsForTask(taskId)
const toastManager = getTaskToastManager()
if (toastManager) {
toastManager.removeTask(taskId)
}
this.tasks.delete(taskId)
if (task.sessionID) {
subagentSessions.delete(task.sessionID)
@@ -1423,24 +1459,55 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
}
}
private async checkAndInterruptStaleTasks(): Promise<void> {
private async checkAndInterruptStaleTasks(
allStatuses: Record<string, { type: string }> = {},
): Promise<void> {
const staleTimeoutMs = this.config?.staleTimeoutMs ?? DEFAULT_STALE_TIMEOUT_MS
const messageStalenessMs = this.config?.messageStalenessTimeoutMs ?? DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS
const now = Date.now()
for (const task of this.tasks.values()) {
if (task.status !== "running") continue
if (!task.progress?.lastUpdate) continue
const startedAt = task.startedAt
const sessionID = task.sessionID
if (!startedAt || !sessionID) continue
const sessionStatus = allStatuses[sessionID]?.type
const sessionIsRunning = sessionStatus !== undefined && sessionStatus !== "idle"
const runtime = now - startedAt.getTime()
if (!task.progress?.lastUpdate) {
if (sessionIsRunning) continue
if (runtime <= messageStalenessMs) continue
const staleMinutes = Math.round(runtime / 60000)
task.status = "cancelled"
task.error = `Stale timeout (no activity for ${staleMinutes}min since start)`
task.completedAt = new Date()
if (task.concurrencyKey) {
this.concurrencyManager.release(task.concurrencyKey)
task.concurrencyKey = undefined
}
this.client.session.abort({ path: { id: sessionID } }).catch(() => {})
log(`[background-agent] Task ${task.id} interrupted: no progress since start`)
try {
await this.enqueueNotificationForParent(task.parentSessionID, () => this.notifyParentSession(task))
} catch (err) {
log("[background-agent] Error in notifyParentSession for stale task:", { taskId: task.id, error: err })
}
continue
}
if (sessionIsRunning) continue
if (runtime < MIN_RUNTIME_BEFORE_STALE_MS) continue
const timeSinceLastUpdate = now - task.progress.lastUpdate.getTime()
if (timeSinceLastUpdate <= staleTimeoutMs) continue
if (task.status !== "running") continue
const staleMinutes = Math.round(timeSinceLastUpdate / 60000)
@@ -1453,10 +1520,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
task.concurrencyKey = undefined
}
this.client.session.abort({
path: { id: sessionID },
}).catch(() => {})
this.client.session.abort({ path: { id: sessionID } }).catch(() => {})
log(`[background-agent] Task ${task.id} interrupted: stale timeout`)
try {
@@ -1469,11 +1533,12 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
private async pollRunningTasks(): Promise<void> {
this.pruneStaleTasksAndNotifications()
await this.checkAndInterruptStaleTasks()
const statusResult = await this.client.session.status()
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>
await this.checkAndInterruptStaleTasks(allStatuses)
for (const task of this.tasks.values()) {
if (task.status !== "running") continue
@@ -1483,7 +1548,6 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
try {
const sessionStatus = allStatuses[sessionID]
// Don't skip if session not in status - fall through to message-based detection
if (sessionStatus?.type === "idle") {
// Edge guard: Validate session has actual output before completing
const hasValidOutput = await this.validateSessionHasOutput(sessionID)

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

@@ -0,0 +1,105 @@
import { beforeEach, describe, expect, mock, test } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
const executeCompactMock = mock(async () => {})
const getLastAssistantMock = mock(async () => ({
providerID: "anthropic",
modelID: "claude-sonnet-4-5",
}))
const parseAnthropicTokenLimitErrorMock = mock(() => ({
providerID: "anthropic",
modelID: "claude-sonnet-4-5",
}))
mock.module("./executor", () => ({
executeCompact: executeCompactMock,
getLastAssistant: getLastAssistantMock,
}))
mock.module("./parser", () => ({
parseAnthropicTokenLimitError: parseAnthropicTokenLimitErrorMock,
}))
mock.module("../../shared/logger", () => ({
log: () => {},
}))
function createMockContext(): PluginInput {
return {
client: {
session: {
messages: mock(() => Promise.resolve({ data: [] })),
},
tui: {
showToast: mock(() => Promise.resolve()),
},
},
directory: "/tmp",
} as PluginInput
}
function setupDelayedTimeoutMocks(): {
restore: () => void
getClearTimeoutCalls: () => Array<ReturnType<typeof setTimeout>>
} {
const originalSetTimeout = globalThis.setTimeout
const originalClearTimeout = globalThis.clearTimeout
const clearTimeoutCalls: Array<ReturnType<typeof setTimeout>> = []
let timeoutCounter = 0
globalThis.setTimeout = ((_: () => void, _delay?: number) => {
timeoutCounter += 1
return timeoutCounter as ReturnType<typeof setTimeout>
}) as typeof setTimeout
globalThis.clearTimeout = ((timeoutID: ReturnType<typeof setTimeout>) => {
clearTimeoutCalls.push(timeoutID)
}) as typeof clearTimeout
return {
restore: () => {
globalThis.setTimeout = originalSetTimeout
globalThis.clearTimeout = originalClearTimeout
},
getClearTimeoutCalls: () => clearTimeoutCalls,
}
}
describe("createAnthropicContextWindowLimitRecoveryHook", () => {
beforeEach(() => {
executeCompactMock.mockClear()
getLastAssistantMock.mockClear()
parseAnthropicTokenLimitErrorMock.mockClear()
})
test("cancels pending timer when session.idle handles compaction first", async () => {
//#given
const { restore, getClearTimeoutCalls } = setupDelayedTimeoutMocks()
const { createAnthropicContextWindowLimitRecoveryHook } = await import("./recovery-hook")
const hook = createAnthropicContextWindowLimitRecoveryHook(createMockContext())
try {
//#when
await hook.event({
event: {
type: "session.error",
properties: { sessionID: "session-race", error: "prompt is too long" },
},
})
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "session-race" },
},
})
//#then
expect(getClearTimeoutCalls()).toEqual([1 as ReturnType<typeof setTimeout>])
expect(executeCompactMock).toHaveBeenCalledTimes(1)
expect(executeCompactMock.mock.calls[0]?.[0]).toBe("session-race")
} finally {
restore()
}
})
})

View File

@@ -28,6 +28,7 @@ export function createAnthropicContextWindowLimitRecoveryHook(
) {
const autoCompactState = createRecoveryState()
const experimental = options?.experimental
const pendingCompactionTimeoutBySession = new Map<string, ReturnType<typeof setTimeout>>()
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
const props = event.properties as Record<string, unknown> | undefined
@@ -35,6 +36,12 @@ export function createAnthropicContextWindowLimitRecoveryHook(
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id) {
const timeoutID = pendingCompactionTimeoutBySession.get(sessionInfo.id)
if (timeoutID !== undefined) {
clearTimeout(timeoutID)
pendingCompactionTimeoutBySession.delete(sessionInfo.id)
}
autoCompactState.pendingCompact.delete(sessionInfo.id)
autoCompactState.errorDataBySession.delete(sessionInfo.id)
autoCompactState.retryStateBySession.delete(sessionInfo.id)
@@ -76,7 +83,8 @@ export function createAnthropicContextWindowLimitRecoveryHook(
})
.catch(() => {})
setTimeout(() => {
const timeoutID = setTimeout(() => {
pendingCompactionTimeoutBySession.delete(sessionID)
executeCompact(
sessionID,
{ providerID, modelID },
@@ -86,6 +94,8 @@ export function createAnthropicContextWindowLimitRecoveryHook(
experimental,
)
}, 300)
pendingCompactionTimeoutBySession.set(sessionID, timeoutID)
}
return
}
@@ -114,6 +124,12 @@ export function createAnthropicContextWindowLimitRecoveryHook(
if (!autoCompactState.pendingCompact.has(sessionID)) return
const timeoutID = pendingCompactionTimeoutBySession.get(sessionID)
if (timeoutID !== undefined) {
clearTimeout(timeoutID)
pendingCompactionTimeoutBySession.delete(sessionID)
}
const errorData = autoCompactState.errorDataBySession.get(sessionID)
const lastAssistant = await getLastAssistant(sessionID, ctx.client, ctx.directory)

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,5 +1,12 @@
import { describe, it, expect, mock, beforeEach } from "bun:test"
import { createPreemptiveCompactionHook } from "./preemptive-compaction"
const logMock = mock(() => {})
mock.module("../shared/logger", () => ({
log: logMock,
}))
const { createPreemptiveCompactionHook } = await import("./preemptive-compaction")
function createMockCtx() {
return {
@@ -21,6 +28,7 @@ describe("preemptive-compaction", () => {
beforeEach(() => {
ctx = createMockCtx()
logMock.mockClear()
})
// #given event caches token info from message.updated
@@ -152,4 +160,45 @@ describe("preemptive-compaction", () => {
expect(ctx.client.session.summarize).not.toHaveBeenCalled()
})
it("should log summarize errors instead of swallowing them", async () => {
//#given
const hook = createPreemptiveCompactionHook(ctx as never)
const sessionID = "ses_log_error"
const summarizeError = new Error("summarize failed")
ctx.client.session.summarize.mockRejectedValueOnce(summarizeError)
await hook.event({
event: {
type: "message.updated",
properties: {
info: {
role: "assistant",
sessionID,
providerID: "anthropic",
modelID: "claude-sonnet-4-5",
finish: true,
tokens: {
input: 170000,
output: 0,
reasoning: 0,
cache: { read: 10000, write: 0 },
},
},
},
},
})
//#when
await hook["tool.execute.after"](
{ tool: "bash", sessionID, callID: "call_log" },
{ title: "", output: "test", metadata: null }
)
//#then
expect(logMock).toHaveBeenCalledWith("[preemptive-compaction] Compaction failed", {
sessionID,
error: String(summarizeError),
})
})
})

View File

@@ -1,3 +1,5 @@
import { log } from "../shared/logger"
const DEFAULT_ACTUAL_LIMIT = 200_000
const ANTHROPIC_ACTUAL_LIMIT =
@@ -76,8 +78,8 @@ export function createPreemptiveCompactionHook(ctx: PluginInput) {
})
compactedSessions.add(sessionID)
} catch {
// best-effort; do not disrupt tool execution
} catch (error) {
log("[preemptive-compaction] Compaction failed", { sessionID, error: String(error) })
} finally {
compactionInProgress.delete(sessionID)
}

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

@@ -18,3 +18,5 @@ export const COUNTDOWN_GRACE_PERIOD_MS = 500
export const ABORT_WINDOW_MS = 3000
export const CONTINUATION_COOLDOWN_MS = 30_000
export const MAX_CONSECUTIVE_FAILURES = 5
export const FAILURE_RESET_WINDOW_MS = 5 * 60 * 1000

View File

@@ -141,11 +141,14 @@ ${todoList}`
if (injectionState) {
injectionState.inFlight = false
injectionState.lastInjectedAt = Date.now()
injectionState.consecutiveFailures = 0
}
} catch (error) {
log(`[${HOOK_NAME}] Injection failed`, { sessionID, error: String(error) })
if (injectionState) {
injectionState.inFlight = false
injectionState.lastInjectedAt = Date.now()
injectionState.consecutiveFailures = (injectionState.consecutiveFailures ?? 0) + 1
}
}
}

View File

@@ -8,7 +8,9 @@ import {
ABORT_WINDOW_MS,
CONTINUATION_COOLDOWN_MS,
DEFAULT_SKIP_AGENTS,
FAILURE_RESET_WINDOW_MS,
HOOK_NAME,
MAX_CONSECUTIVE_FAILURES,
} from "./constants"
import { isLastAssistantMessageAborted } from "./abort-detection"
import { getIncompleteCount } from "./todo"
@@ -99,8 +101,35 @@ export async function handleSessionIdle(args: {
return
}
if (state.lastInjectedAt && Date.now() - state.lastInjectedAt < CONTINUATION_COOLDOWN_MS) {
log(`[${HOOK_NAME}] Skipped: cooldown active`, { sessionID })
if (
state.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES
&& state.lastInjectedAt
&& Date.now() - state.lastInjectedAt >= FAILURE_RESET_WINDOW_MS
) {
state.consecutiveFailures = 0
log(`[${HOOK_NAME}] Reset consecutive failures after recovery window`, {
sessionID,
failureResetWindowMs: FAILURE_RESET_WINDOW_MS,
})
}
if (state.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
log(`[${HOOK_NAME}] Skipped: max consecutive failures reached`, {
sessionID,
consecutiveFailures: state.consecutiveFailures,
maxConsecutiveFailures: MAX_CONSECUTIVE_FAILURES,
})
return
}
const effectiveCooldown =
CONTINUATION_COOLDOWN_MS * Math.pow(2, Math.min(state.consecutiveFailures, 5))
if (state.lastInjectedAt && Date.now() - state.lastInjectedAt < effectiveCooldown) {
log(`[${HOOK_NAME}] Skipped: cooldown active`, {
sessionID,
effectiveCooldown,
consecutiveFailures: state.consecutiveFailures,
})
return
}

View File

@@ -45,7 +45,9 @@ export function createSessionStateStore(): SessionStateStore {
return existing.state
}
const state: SessionState = {}
const state: SessionState = {
consecutiveFailures: 0,
}
sessions.set(sessionID, { state, lastAccessedAt: Date.now() })
return state
}

View File

@@ -1,18 +1,26 @@
/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import type { BackgroundManager } from "../../features/background-agent"
import { setMainSession, subagentSessions, _resetForTesting } from "../../features/claude-code-session-state"
import { createTodoContinuationEnforcer } from "."
import { CONTINUATION_COOLDOWN_MS } from "./constants"
import {
CONTINUATION_COOLDOWN_MS,
FAILURE_RESET_WINDOW_MS,
MAX_CONSECUTIVE_FAILURES,
} from "./constants"
type TimerCallback = (...args: any[]) => void
interface FakeTimers {
advanceBy: (ms: number, advanceClock?: boolean) => Promise<void>
advanceClockBy: (ms: number) => Promise<void>
restore: () => void
}
function createFakeTimers(): FakeTimers {
const FAKE_MIN_DELAY_MS = 500
const REAL_MAX_DELAY_MS = 5000
const originalNow = Date.now()
let clockNow = originalNow
let timerNow = 0
@@ -52,20 +60,41 @@ function createFakeTimers(): FakeTimers {
}
globalThis.setTimeout = ((callback: TimerCallback, delay?: number, ...args: any[]) => {
return schedule(callback, delay, null, args) as unknown as ReturnType<typeof setTimeout>
const normalized = normalizeDelay(delay)
if (normalized < FAKE_MIN_DELAY_MS) {
return original.setTimeout(callback, delay, ...args)
}
if (normalized >= REAL_MAX_DELAY_MS) {
return original.setTimeout(callback, delay, ...args)
}
return schedule(callback, normalized, null, args) as unknown as ReturnType<typeof setTimeout>
}) as typeof setTimeout
globalThis.setInterval = ((callback: TimerCallback, delay?: number, ...args: any[]) => {
const interval = normalizeDelay(delay)
return schedule(callback, delay, interval, args) as unknown as ReturnType<typeof setInterval>
if (interval < FAKE_MIN_DELAY_MS) {
return original.setInterval(callback, delay, ...args)
}
if (interval >= REAL_MAX_DELAY_MS) {
return original.setInterval(callback, delay, ...args)
}
return schedule(callback, interval, interval, args) as unknown as ReturnType<typeof setInterval>
}) as typeof setInterval
globalThis.clearTimeout = ((id?: number) => {
clear(id)
globalThis.clearTimeout = ((id?: Parameters<typeof clearTimeout>[0]) => {
if (typeof id === "number" && timers.has(id)) {
clear(id)
return
}
original.clearTimeout(id)
}) as typeof clearTimeout
globalThis.clearInterval = ((id?: number) => {
clear(id)
globalThis.clearInterval = ((id?: Parameters<typeof clearInterval>[0]) => {
if (typeof id === "number" && timers.has(id)) {
clear(id)
return
}
original.clearInterval(id)
}) as typeof clearInterval
Date.now = () => clockNow
@@ -107,6 +136,12 @@ function createFakeTimers(): FakeTimers {
await Promise.resolve()
}
const advanceClockBy = async (ms: number) => {
const clamped = Math.max(0, ms)
clockNow += clamped
await Promise.resolve()
}
const restore = () => {
globalThis.setTimeout = original.setTimeout
globalThis.clearTimeout = original.clearTimeout
@@ -115,7 +150,7 @@ function createFakeTimers(): FakeTimers {
Date.now = original.dateNow
}
return { advanceBy, restore }
return { advanceBy, advanceClockBy, restore }
}
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
@@ -133,6 +168,15 @@ describe("todo-continuation-enforcer", () => {
}
}
interface PromptRequestOptions {
path: { id: string }
body: {
agent?: string
model?: { providerID?: string; modelID?: string }
parts: Array<{ text: string }>
}
}
let mockMessages: MockMessage[] = []
function createMockPluginInput() {
@@ -510,7 +554,7 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(2500, true)
await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true)
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
@@ -518,8 +562,166 @@ describe("todo-continuation-enforcer", () => {
//#then
expect(promptCalls).toHaveLength(2)
}, { timeout: 15000 })
test("should apply cooldown even after injection failure", async () => {
//#given
const sessionID = "main-failure-cooldown"
setMainSession(sessionID)
const mockInput = createMockPluginInput()
mockInput.client.session.promptAsync = async (opts: PromptRequestOptions) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
throw new Error("simulated auth failure")
}
const hook = createTodoContinuationEnforcer(mockInput, {})
//#when
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
//#then
expect(promptCalls).toHaveLength(1)
})
test("should stop retries after max consecutive failures", async () => {
//#given
const sessionID = "main-max-consecutive-failures"
setMainSession(sessionID)
const mockInput = createMockPluginInput()
mockInput.client.session.promptAsync = async (opts: PromptRequestOptions) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
throw new Error("simulated auth failure")
}
const hook = createTodoContinuationEnforcer(mockInput, {})
//#when
for (let index = 0; index < MAX_CONSECUTIVE_FAILURES; index++) {
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
if (index < MAX_CONSECUTIVE_FAILURES - 1) {
await fakeTimers.advanceClockBy(1_000_000)
}
}
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
//#then
expect(promptCalls).toHaveLength(MAX_CONSECUTIVE_FAILURES)
}, { timeout: 30000 })
test("should resume retries after reset window when max failures reached", async () => {
//#given
const sessionID = "main-recovery-after-max-failures"
setMainSession(sessionID)
const mockInput = createMockPluginInput()
mockInput.client.session.promptAsync = async (opts: PromptRequestOptions) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
throw new Error("simulated auth failure")
}
const hook = createTodoContinuationEnforcer(mockInput, {})
//#when
for (let index = 0; index < MAX_CONSECUTIVE_FAILURES; index++) {
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
if (index < MAX_CONSECUTIVE_FAILURES - 1) {
await fakeTimers.advanceClockBy(1_000_000)
}
}
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
await fakeTimers.advanceClockBy(FAILURE_RESET_WINDOW_MS)
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
//#then
expect(promptCalls).toHaveLength(MAX_CONSECUTIVE_FAILURES + 1)
}, { timeout: 30000 })
test("should increase cooldown exponentially after consecutive failures", async () => {
//#given
const sessionID = "main-exponential-backoff"
setMainSession(sessionID)
const mockInput = createMockPluginInput()
mockInput.client.session.promptAsync = async (opts: PromptRequestOptions) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
throw new Error("simulated auth failure")
}
const hook = createTodoContinuationEnforcer(mockInput, {})
//#when
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
//#then
expect(promptCalls).toHaveLength(2)
}, { timeout: 30000 })
test("should reset consecutive failure count after successful injection", async () => {
//#given
const sessionID = "main-reset-consecutive-failures"
setMainSession(sessionID)
let shouldFail = true
const mockInput = createMockPluginInput()
mockInput.client.session.promptAsync = async (opts: PromptRequestOptions) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
if (shouldFail) {
shouldFail = false
throw new Error("simulated auth failure")
}
return {}
}
const hook = createTodoContinuationEnforcer(mockInput, {})
//#when
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS * 2)
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
//#then
expect(promptCalls).toHaveLength(3)
}, { timeout: 30000 })
test("should keep injecting even when todos remain unchanged across cycles", async () => {
//#given
const sessionID = "main-no-stagnation-cap"
@@ -534,26 +736,26 @@ describe("todo-continuation-enforcer", () => {
//#when — 5 consecutive idle cycles with unchanged todos
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true)
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true)
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true)
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true)
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
//#then — all 5 injections should fire (no stagnation cap)
expect(promptCalls).toHaveLength(5)
})
}, { timeout: 60000 })
test("should skip idle handling while injection is in flight", async () => {
//#given
@@ -613,7 +815,7 @@ describe("todo-continuation-enforcer", () => {
//#then
expect(promptCalls).toHaveLength(2)
})
}, { timeout: 15000 })
test("should accept skipAgents option without error", async () => {
// given - session with skipAgents configured for Prometheus

View File

@@ -29,6 +29,7 @@ export interface SessionState {
abortDetectedAt?: number
lastInjectedAt?: number
inFlight?: boolean
consecutiveFailures: number
}
export interface MessageInfo {

View File

@@ -0,0 +1,118 @@
import { describe, test, expect } from "bun:test"
import { createChatMessageHandler } from "./chat-message"
type ChatMessagePart = { type: string; text?: string; [key: string]: unknown }
type ChatMessageHandlerOutput = { message: Record<string, unknown>; parts: ChatMessagePart[] }
function createMockHandlerArgs(overrides?: {
pluginConfig?: Record<string, unknown>
shouldOverride?: boolean
}) {
const appliedSessions: string[] = []
return {
ctx: { client: { tui: { showToast: async () => {} } } } as any,
pluginConfig: (overrides?.pluginConfig ?? {}) as any,
firstMessageVariantGate: {
shouldOverride: () => overrides?.shouldOverride ?? false,
markApplied: (sessionID: string) => { appliedSessions.push(sessionID) },
},
hooks: {
stopContinuationGuard: null,
keywordDetector: null,
claudeCodeHooks: null,
autoSlashCommand: null,
startWork: null,
ralphLoop: null,
} as any,
_appliedSessions: appliedSessions,
}
}
function createMockInput(agent?: string, model?: { providerID: string; modelID: string }) {
return {
sessionID: "test-session",
agent,
model,
}
}
function createMockOutput(variant?: string): ChatMessageHandlerOutput {
const message: Record<string, unknown> = {}
if (variant !== undefined) {
message["variant"] = variant
}
return { message, parts: [] }
}
describe("createChatMessageHandler - first message variant", () => {
test("first message: sets variant from fallback chain when user has no selection", async () => {
//#given - first message, no user-selected variant, hephaestus with medium in chain
const args = createMockHandlerArgs({ shouldOverride: true })
const handler = createChatMessageHandler(args)
const input = createMockInput("hephaestus", { providerID: "openai", modelID: "gpt-5.3-codex" })
const output = createMockOutput() // no variant set
//#when
await handler(input, output)
//#then - should set variant from fallback chain
expect(output.message["variant"]).toBeDefined()
})
test("first message: preserves user-selected variant when already set", async () => {
//#given - first message, user already selected "xhigh" variant in OpenCode UI
const args = createMockHandlerArgs({ shouldOverride: true })
const handler = createChatMessageHandler(args)
const input = createMockInput("hephaestus", { providerID: "openai", modelID: "gpt-5.3-codex" })
const output = createMockOutput("xhigh") // user selected xhigh
//#when
await handler(input, output)
//#then - user's xhigh must be preserved, not overwritten to "medium"
expect(output.message["variant"]).toBe("xhigh")
})
test("first message: preserves user-selected 'high' variant", async () => {
//#given - user selected "high" variant
const args = createMockHandlerArgs({ shouldOverride: true })
const handler = createChatMessageHandler(args)
const input = createMockInput("hephaestus", { providerID: "openai", modelID: "gpt-5.3-codex" })
const output = createMockOutput("high")
//#when
await handler(input, output)
//#then
expect(output.message["variant"]).toBe("high")
})
test("subsequent message: does not override existing variant", async () => {
//#given - not first message, variant already set
const args = createMockHandlerArgs({ shouldOverride: false })
const handler = createChatMessageHandler(args)
const input = createMockInput("hephaestus", { providerID: "openai", modelID: "gpt-5.3-codex" })
const output = createMockOutput("xhigh")
//#when
await handler(input, output)
//#then
expect(output.message["variant"]).toBe("xhigh")
})
test("first message: marks gate as applied regardless of variant presence", async () => {
//#given - first message with user-selected variant
const args = createMockHandlerArgs({ shouldOverride: true })
const handler = createChatMessageHandler(args)
const input = createMockInput("hephaestus", { providerID: "openai", modelID: "gpt-5.3-codex" })
const output = createMockOutput("xhigh")
//#when
await handler(input, output)
//#then - gate should still be marked as applied
expect(args._appliedSessions).toContain("test-session")
})
})

View File

@@ -56,12 +56,14 @@ export function createChatMessageHandler(args: {
const message = output.message
if (firstMessageVariantGate.shouldOverride(input.sessionID)) {
const variant =
input.model && input.agent
? resolveVariantForModel(pluginConfig, input.agent, input.model)
: resolveAgentVariant(pluginConfig, input.agent)
if (variant !== undefined) {
message["variant"] = variant
if (message["variant"] === undefined) {
const variant =
input.model && input.agent
? resolveVariantForModel(pluginConfig, input.agent, input.model)
: resolveAgentVariant(pluginConfig, input.agent)
if (variant !== undefined) {
message["variant"] = variant
}
}
firstMessageVariantGate.markApplied(input.sessionID)
} else {

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")
})
})

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