Compare commits

..

4 Commits

Author SHA1 Message Date
YeonGyu-Kim
271929a9e4 ci: restore mock.module() overrides in afterAll to prevent cross-file pollution
Add afterAll hooks that restore original module implementations after
mock.module() overrides. This prevents mock state from leaking across
test files when bun runs them in the same process. Pattern: capture
original module with await import() before mocking, restore in afterAll.
2026-02-14 16:19:55 +09:00
YeonGyu-Kim
945329e261 fix: prevent node:fs mock pollution in directory injector tests
Move mock.module() calls from top-level to beforeEach and restore in
afterEach to prevent readFileSync mock from leaking into other test
files. Use dynamic import with cache-busting query to get fresh modules.
2026-02-14 16:19:40 +09:00
YeonGyu-Kim
f27733eae2 fix: correct test type casts, timeouts, and mock structures
- Fix PluginInput type casts to use 'as unknown as PluginInput'
- Add explicit TodoSnapshot type annotations
- Add timeouts to slow todo-continuation-enforcer tests
- Remove unnecessary test storage mocks in atlas and prometheus-md-only
- Restructure sync-executor mocks to use beforeEach/afterEach pattern
2026-02-14 16:19:29 +09:00
YeonGyu-Kim
e9c9cb696d fix: resolve symlinks in skill config source discovery and test paths
Use fs.realpath() in config-source-discovery to resolve symlinks before
loading skills, preventing duplicate/mismatched paths on systems where
tmpdir() returns a symlink (e.g., macOS /var → /private/var). Also adds
agents-config-dir utility for ~/.agents path resolution.
2026-02-14 16:19:18 +09:00
96 changed files with 1017 additions and 3363 deletions

View File

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

View File

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

View File

@@ -28,13 +28,13 @@
"typescript": "^5.7.3",
},
"optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.5.5",
"oh-my-opencode-darwin-x64": "3.5.5",
"oh-my-opencode-linux-arm64": "3.5.5",
"oh-my-opencode-linux-arm64-musl": "3.5.5",
"oh-my-opencode-linux-x64": "3.5.5",
"oh-my-opencode-linux-x64-musl": "3.5.5",
"oh-my-opencode-windows-x64": "3.5.5",
"oh-my-opencode-darwin-arm64": "3.5.2",
"oh-my-opencode-darwin-x64": "3.5.2",
"oh-my-opencode-linux-arm64": "3.5.2",
"oh-my-opencode-linux-arm64-musl": "3.5.2",
"oh-my-opencode-linux-x64": "3.5.2",
"oh-my-opencode-linux-x64-musl": "3.5.2",
"oh-my-opencode-windows-x64": "3.5.2",
},
},
},
@@ -226,19 +226,19 @@
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.5.5", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-XtcCQ8/iVT6T1B58y0N1oMgOK4beTW8DW98b/ITnINb7b3hNSv5754Af/2Rx67BV0iE0ezC6uXaqz45C7ru1rw=="],
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.5.2", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-oIS3lB2F9/N+3mF5wCKk6/EPVSz516XWN+mNdquSSeddw+xqMxGdhKY6K/XeYbHJzeN2Z8IOikNEJ6psR2/a8g=="],
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.5.5", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-ReSDqU6jihh7lpGNmEt3REzc5bOcyfv3cMHitpecKq0wRrJoTBI+dgNPk90BLjHobGbhAm0TE8VZ9tqTkivnIQ=="],
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.5.2", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-OAdXo4ZCCYO4kRWtnyz3tdmaGYPUB3WcXimXAxp+/sEZxAnh7n1RQkpLn6UxWX4AIAdRT9dfrOfRic6VoCYv2g=="],
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.5.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": ["oh-my-opencode-linux-arm64@3.5.2", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-5XXNMFhp1VsyrGNRBoXcOyoaUeVkbrWkBRPDGZfpiq+kRXH3aaSWdR5G7Pl/TadOQv9Bl8/8YaxsuHRTFT1aXw=="],
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.5.5", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-m9r4OW1XhGtm/SvHM3kzpS4pEiI2eIh5Tj+j5hpMW3wu+AqE3F1XGUpu8RgvIpupFo8beimJWDYQujqokReQqg=="],
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.5.2", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-/woIpqvEI85MgJvEVnz4g5FBLeiQNK7srRsueIFPBmtTahh42HFleCDaIltOl/ndjsE5nCHacQVJHkC9W9/F3Q=="],
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.5.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": ["oh-my-opencode-linux-x64@3.5.2", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-vTL2A+6zzGhi+m7sC8peLDq5OAp2dRR0UEb4RbZAOHtlEruF7qFEmcK3ccWxwc3+Z3G/ITfwn5VNa72ZS4pNTg=="],
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.5.5", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-MOxW1FMTJT3Ze/U2fDedcZUYTFaA9PaKIiqtsBIHOSb+fFgdo51RIuUlKCELN/g9I9dYhw0yP2n9tBMBG6feSg=="],
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.5.2", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-bOAA55snLsK2QB00IkQy8le0Oqh/GJ7pxEHtm1oUezlQrW/nX5SS/hJ7dPHMmOd9FoiqnqyqWZxNkLmFoG463A=="],
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.5.5", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-dWRtPyIdMFQIw1BwVO4PbGqoo0UWs7NES+YJC7BLGv0YnWN7Q2tatmOviSeSgMELeMsWSbDNisEB79jsfShXjA=="],
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.5.2", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-fnHiAPYglw3unPckmQBoCT6+VqjSWCE3S3J551mRo0ZFrxuEP2ZKyHZeFMMOtKwDepCvmKgd1W040+KmuVUXOA=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1471,54 +1471,6 @@
"created_at": "2026-02-14T04:15:19Z",
"repoId": 1108837393,
"pullRequestNo": 1827
},
{
"name": "morphaxl",
"id": 57144942,
"comment_id": 3872741516,
"created_at": "2026-02-09T16:21:56Z",
"repoId": 1108837393,
"pullRequestNo": 1699
},
{
"name": "morphaxl",
"id": 57144942,
"comment_id": 3872742242,
"created_at": "2026-02-09T16:22:04Z",
"repoId": 1108837393,
"pullRequestNo": 1699
},
{
"name": "liu-qingyuan",
"id": 57737268,
"comment_id": 3902402078,
"created_at": "2026-02-14T19:39:58Z",
"repoId": 1108837393,
"pullRequestNo": 1844
},
{
"name": "iyoda",
"id": 31020,
"comment_id": 3902426789,
"created_at": "2026-02-14T19:58:19Z",
"repoId": 1108837393,
"pullRequestNo": 1845
},
{
"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(prompt).toMatch(/negative/i)
expect(lowerPrompt).toMatch(/negative scenario/)
})
test("should require QA scenario adequacy in self-review checklist", () => {

View File

@@ -129,21 +129,7 @@ Your ONLY valid output locations are \`.sisyphus/plans/*.md\` and \`.sisyphus/dr
Example: \`.sisyphus/plans/auth-refactor.md\`
### 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)
### 5. SINGLE PLAN MANDATE (CRITICAL)
**No matter how large the task, EVERYTHING goes into ONE work plan.**
**NEVER:**
@@ -166,7 +152,7 @@ unblocking maximum parallelism in subsequent waves.
**The plan can have 50+ TODOs. That's OK. ONE PLAN.**
### 6.1 SINGLE ATOMIC WRITE (CRITICAL - Prevents Content Loss)
### 5.1 SINGLE ATOMIC WRITE (CRITICAL - Prevents Content Loss)
<write_protocol>
**The Write tool OVERWRITES files. It does NOT append.**
@@ -202,7 +188,7 @@ unblocking maximum parallelism in subsequent waves.
- [ ] File already exists with my content? → Use Edit to append, NOT Write
</write_protocol>
### 7. DRAFT AS WORKING MEMORY (MANDATORY)
### 6. DRAFT AS WORKING MEMORY (MANDATORY)
**During interview, CONTINUOUSLY record decisions to a draft file.**
**Draft Location**: \`.sisyphus/drafts/{name}.md\`

View File

@@ -70,25 +70,108 @@ Generate plan to: \`.sisyphus/plans/{name}.md\`
## Verification Strategy (MANDATORY)
> **ZERO HUMAN INTERVENTION** — ALL verification is agent-executed. No exceptions.
> Acceptance criteria requiring "user manually tests/confirms" are FORBIDDEN.
> **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.
### 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
### QA Policy
Every task MUST include agent-executed QA scenarios (see TODO template below).
Evidence saved to \`.sisyphus/evidence/task-{N}-{scenario-slug}.{ext}\`.
### If TDD Enabled
| 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 |
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
---
@@ -98,82 +181,49 @@ Evidence saved to \`.sisyphus/evidence/task-{N}-{scenario-slug}.{ext}\`.
> 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 — 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 1 (Start Immediately):
├── Task 1: [no dependencies]
── Task 5: [no dependencies]
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 2 (After Wave 1):
├── Task 2: [depends: 1]
├── Task 3: [depends: 1]
── Task 6: [depends: 5]
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]
Wave 3 (After Wave 2):
── Task 4: [depends: 2, 3]
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)
Critical Path: Task 1 → Task 2 → Task 4
Parallel Speedup: ~40% faster than sequential
\`\`\`
### Dependency Matrix (abbreviated — show ALL tasks in your generated plan)
### Dependency Matrix
| 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.
| 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 |
### Agent Dispatch Summary
| 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\` |
| 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 |
---
## TODOs
> Implementation + Test = ONE Task. Never separate.
> EVERY task MUST have: Recommended Agent Profile + Parallelization info + QA Scenarios.
> **A task WITHOUT QA Scenarios is INCOMPLETE. No exceptions.**
> EVERY task MUST have: Recommended Agent Profile + Parallelization info.
- [ ] 1. [Task Title]
@@ -207,15 +257,22 @@ Max Concurrent: 7 (Waves 1 & 2)
**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
@@ -226,60 +283,113 @@ Max Concurrent: 7 (Waves 1 & 2)
> **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)
**QA Scenarios (MANDATORY — task is INCOMPLETE without these):**
**Agent-Executed QA Scenarios (MANDATORY — per-scenario, ultra-detailed):**
> **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.**
> 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):**
\\\`\\\`\\\`
Scenario: [Happy path — what SHOULD work]
Tool: [Playwright / interactive_bash / Bash (curl)]
Preconditions: [Exact setup state]
Scenario: Successful login redirects to dashboard
Tool: Playwright (playwright skill)
Preconditions: Dev server running on localhost:3000, test user exists
Steps:
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}
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
Scenario: [Failure/edge case — what SHOULD fail gracefully]
Tool: [same format]
Preconditions: [Invalid input / missing dependency / error state]
Scenario: Login fails with invalid credentials
Tool: Playwright (playwright skill)
Preconditions: Dev server running, no valid user with these credentials
Steps:
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}
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
\\\`\\\`\\\`
> **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
**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
\\\`\\\`\\\`
**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\`
@@ -288,28 +398,6 @@ Max Concurrent: 7 (Waves 1 & 2)
---
## 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": "opencode/glm-4.7-free",
"model": "openai/gpt-5.2",
},
},
}
@@ -314,7 +314,7 @@ exports[`generateModelConfig single native provider uses OpenAI models with isMa
"model": "opencode/glm-4.7-free",
},
"writing": {
"model": "opencode/glm-4.7-free",
"model": "openai/gpt-5.2",
},
},
}
@@ -372,7 +372,6 @@ exports[`generateModelConfig single native provider uses Gemini models when only
},
"visual-engineering": {
"model": "google/gemini-3-pro",
"variant": "high",
},
"writing": {
"model": "google/gemini-3-flash",
@@ -433,7 +432,6 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
},
"visual-engineering": {
"model": "google/gemini-3-pro",
"variant": "high",
},
"writing": {
"model": "google/gemini-3-flash",
@@ -507,7 +505,6 @@ exports[`generateModelConfig all native providers uses preferred models from fal
},
"visual-engineering": {
"model": "google/gemini-3-pro",
"variant": "high",
},
"writing": {
"model": "google/gemini-3-flash",
@@ -582,7 +579,6 @@ exports[`generateModelConfig all native providers uses preferred models with isM
},
"visual-engineering": {
"model": "google/gemini-3-pro",
"variant": "high",
},
"writing": {
"model": "google/gemini-3-flash",
@@ -656,7 +652,6 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on
},
"visual-engineering": {
"model": "opencode/gemini-3-pro",
"variant": "high",
},
"writing": {
"model": "opencode/gemini-3-flash",
@@ -731,7 +726,6 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is
},
"visual-engineering": {
"model": "opencode/gemini-3-pro",
"variant": "high",
},
"writing": {
"model": "opencode/gemini-3-flash",
@@ -805,7 +799,6 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
},
"visual-engineering": {
"model": "github-copilot/gemini-3-pro-preview",
"variant": "high",
},
"writing": {
"model": "github-copilot/gemini-3-flash-preview",
@@ -880,7 +873,6 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
},
"visual-engineering": {
"model": "github-copilot/gemini-3-pro-preview",
"variant": "high",
},
"writing": {
"model": "github-copilot/gemini-3-flash-preview",
@@ -935,10 +927,10 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian whe
"model": "opencode/glm-4.7-free",
},
"visual-engineering": {
"model": "zai-coding-plan/glm-5",
"model": "zai-coding-plan/glm-4.7",
},
"writing": {
"model": "opencode/glm-4.7-free",
"model": "zai-coding-plan/glm-4.7",
},
},
}
@@ -990,10 +982,10 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian wit
"model": "opencode/glm-4.7-free",
},
"visual-engineering": {
"model": "zai-coding-plan/glm-5",
"model": "zai-coding-plan/glm-4.7",
},
"writing": {
"model": "opencode/glm-4.7-free",
"model": "zai-coding-plan/glm-4.7",
},
},
}
@@ -1064,7 +1056,6 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen
},
"visual-engineering": {
"model": "opencode/gemini-3-pro",
"variant": "high",
},
"writing": {
"model": "opencode/gemini-3-flash",
@@ -1138,7 +1129,6 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb
},
"visual-engineering": {
"model": "github-copilot/gemini-3-pro-preview",
"variant": "high",
},
"writing": {
"model": "github-copilot/gemini-3-flash-preview",
@@ -1199,7 +1189,8 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + ZAI combinat
"model": "anthropic/claude-sonnet-4-5",
},
"visual-engineering": {
"model": "zai-coding-plan/glm-5",
"model": "anthropic/claude-opus-4-6",
"variant": "max",
},
"writing": {
"model": "anthropic/claude-sonnet-4-5",
@@ -1265,7 +1256,6 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
},
"visual-engineering": {
"model": "google/gemini-3-pro",
"variant": "high",
},
"writing": {
"model": "google/gemini-3-flash",
@@ -1339,7 +1329,6 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
},
"visual-engineering": {
"model": "github-copilot/gemini-3-pro-preview",
"variant": "high",
},
"writing": {
"model": "github-copilot/gemini-3-flash-preview",
@@ -1413,7 +1402,6 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
},
"visual-engineering": {
"model": "google/gemini-3-pro",
"variant": "high",
},
"writing": {
"model": "google/gemini-3-flash",
@@ -1488,7 +1476,6 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
},
"visual-engineering": {
"model": "google/gemini-3-pro",
"variant": "high",
},
"writing": {
"model": "google/gemini-3-flash",

View File

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

View File

@@ -1,6 +1,10 @@
import { afterEach, describe, expect, it, mock } from "bun:test"
import { afterEach, afterAll, describe, expect, it, mock } from "bun:test"
import type { DoctorResult } from "./types"
const realFormatDefault = await import("./format-default")
const realFormatStatus = await import("./format-status")
const realFormatVerbose = await import("./format-verbose")
function createDoctorResult(): DoctorResult {
return {
results: [
@@ -44,6 +48,12 @@ describe("formatter", () => {
mock.restore()
})
afterAll(() => {
mock.module("./format-default", () => ({ ...realFormatDefault }))
mock.module("./format-status", () => ({ ...realFormatStatus }))
mock.module("./format-verbose", () => ({ ...realFormatVerbose }))
})
describe("formatDoctorOutput", () => {
it("dispatches to default formatter for default mode", async () => {
//#given

View File

@@ -1,6 +1,9 @@
import { afterEach, describe, expect, it, mock } from "bun:test"
import { afterEach, afterAll, describe, expect, it, mock } from "bun:test"
import type { CheckDefinition, CheckResult, DoctorResult, SystemInfo, ToolsSummary } from "./types"
const realChecks = await import("./checks")
const realFormatter = await import("./formatter")
function createSystemInfo(): SystemInfo {
return {
opencodeVersion: "1.0.200",
@@ -47,6 +50,11 @@ describe("runner", () => {
mock.restore()
})
afterAll(() => {
mock.module("./checks", () => ({ ...realChecks }))
mock.module("./formatter", () => ({ ...realFormatter }))
})
describe("runCheck", () => {
it("returns fail result with issue when check throws", async () => {
//#given

View File

@@ -1,4 +1,6 @@
import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test"
import { describe, it, expect, beforeEach, afterEach, afterAll, mock } from "bun:test"
const realMcpOauthProvider = await import("../../features/mcp-oauth/provider")
const mockLogin = mock(() => Promise.resolve({ accessToken: "test-token", expiresAt: 1710000000 }))
@@ -11,6 +13,10 @@ mock.module("../../features/mcp-oauth/provider", () => ({
},
}))
afterAll(() => {
mock.module("../../features/mcp-oauth/provider", () => ({ ...realMcpOauthProvider }))
})
const { login } = await import("./login")
describe("login command", () => {

View File

@@ -1,10 +1,13 @@
import { describe, it, expect, mock, spyOn, beforeEach, afterEach } from "bun:test"
import { describe, it, expect, mock, spyOn, beforeEach, afterEach, afterAll } from "bun:test"
import type { RunResult } from "./types"
import { createJsonOutputManager } from "./json-output"
import { resolveSession } from "./session-resolver"
import { executeOnCompleteHook } from "./on-complete-hook"
import type { OpencodeClient } from "./types"
const realSdk = await import("@opencode-ai/sdk")
const realPortUtils = await import("../../shared/port-utils")
const mockServerClose = mock(() => {})
const mockCreateOpencode = mock(() =>
Promise.resolve({
@@ -17,16 +20,23 @@ const mockIsPortAvailable = mock(() => Promise.resolve(true))
const mockGetAvailableServerPort = mock(() => Promise.resolve({ port: 9999, wasAutoSelected: false }))
mock.module("@opencode-ai/sdk", () => ({
...realSdk,
createOpencode: mockCreateOpencode,
createOpencodeClient: mockCreateOpencodeClient,
}))
mock.module("../../shared/port-utils", () => ({
...realPortUtils,
isPortAvailable: mockIsPortAvailable,
getAvailableServerPort: mockGetAvailableServerPort,
DEFAULT_SERVER_PORT: 4096,
}))
afterAll(() => {
mock.module("@opencode-ai/sdk", () => ({ ...realSdk }))
mock.module("../../shared/port-utils", () => ({ ...realPortUtils }))
})
const { createServerConnection } = await import("./server-connection")
interface MockWriteStream {

View File

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

View File

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

View File

@@ -1,4 +1,7 @@
import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test"
import { describe, it, expect, mock, beforeEach, afterEach, afterAll } from "bun:test"
const realSdk = await import("@opencode-ai/sdk")
const realPortUtils = await import("../../shared/port-utils")
const originalConsole = globalThis.console
@@ -15,16 +18,23 @@ const mockGetAvailableServerPort = mock(() => Promise.resolve({ port: 4096, wasA
const mockConsoleLog = mock(() => {})
mock.module("@opencode-ai/sdk", () => ({
...realSdk,
createOpencode: mockCreateOpencode,
createOpencodeClient: mockCreateOpencodeClient,
}))
mock.module("../../shared/port-utils", () => ({
...realPortUtils,
isPortAvailable: mockIsPortAvailable,
getAvailableServerPort: mockGetAvailableServerPort,
DEFAULT_SERVER_PORT: 4096,
}))
afterAll(() => {
mock.module("@opencode-ai/sdk", () => ({ ...realSdk }))
mock.module("../../shared/port-utils", () => ({ ...realPortUtils }))
})
const { createServerConnection } = await import("./server-connection")
describe("createServerConnection", () => {

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ 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
@@ -191,10 +190,6 @@ 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 }>> {
@@ -220,23 +215,6 @@ 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") {
@@ -916,7 +894,7 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () =>
})
describe("BackgroundManager.notifyParentSession - aborted parent", () => {
test("should fall back and still notify when parent session messages are aborted", async () => {
test("should skip notification when parent session is aborted", async () => {
//#given
let promptCalled = false
const promptMock = async () => {
@@ -955,7 +933,7 @@ describe("BackgroundManager.notifyParentSession - aborted parent", () => {
.notifyParentSession(task)
//#then
expect(promptCalled).toBe(true)
expect(promptCalled).toBe(false)
manager.shutdown()
})
@@ -1792,32 +1770,6 @@ 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", () => {
@@ -2337,221 +2289,10 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
getTaskMap(manager).set(task.id, task)
await manager["checkAndInterruptStaleTasks"]()
await manager["checkAndInterruptStaleTasks"]()
expect(task.status).toBe("cancelled")
})
test("should NOT interrupt task when session is running, even with stale lastUpdate", async () => {
//#given
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
const task: BackgroundTask = {
id: "task-running-session",
sessionID: "session-running",
parentSessionID: "parent-rs",
parentMessageID: "msg-rs",
description: "Task with running session",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 300_000),
progress: {
toolCalls: 2,
lastUpdate: new Date(Date.now() - 300_000),
},
}
getTaskMap(manager).set(task.id, task)
//#when — session is actively running
await manager["checkAndInterruptStaleTasks"]({ "session-running": { type: "running" } })
//#then — task survives because session is running
expect(task.status).toBe("running")
})
test("should interrupt task when session is idle and lastUpdate exceeds stale timeout", async () => {
//#given
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
stubNotifyParentSession(manager)
const task: BackgroundTask = {
id: "task-idle-session",
sessionID: "session-idle",
parentSessionID: "parent-is",
parentMessageID: "msg-is",
description: "Task with idle session",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 300_000),
progress: {
toolCalls: 2,
lastUpdate: new Date(Date.now() - 300_000),
},
}
getTaskMap(manager).set(task.id, task)
//#when — session is idle
await manager["checkAndInterruptStaleTasks"]({ "session-idle": { type: "idle" } })
//#then — killed because session is idle with stale lastUpdate
expect(task.status).toBe("cancelled")
expect(task.error).toContain("Stale timeout")
})
test("should NOT interrupt running session even with very old lastUpdate (no safety net)", async () => {
//#given
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
const task: BackgroundTask = {
id: "task-long-running",
sessionID: "session-long",
parentSessionID: "parent-lr",
parentMessageID: "msg-lr",
description: "Long running task",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 900_000),
progress: {
toolCalls: 5,
lastUpdate: new Date(Date.now() - 900_000),
},
}
getTaskMap(manager).set(task.id, task)
//#when — session is running, lastUpdate 15min old
await manager["checkAndInterruptStaleTasks"]({ "session-long": { type: "running" } })
//#then — running sessions are NEVER stale-killed
expect(task.status).toBe("running")
})
test("should NOT interrupt running session with no progress (undefined lastUpdate)", async () => {
//#given — no progress at all, but session is running
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { messageStalenessTimeoutMs: 600_000 })
const task: BackgroundTask = {
id: "task-running-no-progress",
sessionID: "session-rnp",
parentSessionID: "parent-rnp",
parentMessageID: "msg-rnp",
description: "Running no progress",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 15 * 60 * 1000),
progress: undefined,
}
getTaskMap(manager).set(task.id, task)
//#when — session is running despite no progress
await manager["checkAndInterruptStaleTasks"]({ "session-rnp": { type: "running" } })
//#then — running sessions are NEVER killed
expect(task.status).toBe("running")
})
test("should interrupt task with no lastUpdate after messageStalenessTimeout", async () => {
//#given
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { messageStalenessTimeoutMs: 600_000 })
stubNotifyParentSession(manager)
const task: BackgroundTask = {
id: "task-no-update",
sessionID: "session-no-update",
parentSessionID: "parent-nu",
parentMessageID: "msg-nu",
description: "No update task",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 15 * 60 * 1000),
progress: undefined,
}
getTaskMap(manager).set(task.id, task)
//#when — no progress update for 15 minutes
await manager["checkAndInterruptStaleTasks"]({})
//#then — killed after messageStalenessTimeout
expect(task.status).toBe("cancelled")
expect(task.error).toContain("no activity")
})
test("should NOT interrupt task with no lastUpdate within messageStalenessTimeout", async () => {
//#given
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { messageStalenessTimeoutMs: 600_000 })
const task: BackgroundTask = {
id: "task-fresh-no-update",
sessionID: "session-fresh",
parentSessionID: "parent-fn",
parentMessageID: "msg-fn",
description: "Fresh no-update task",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 5 * 60 * 1000),
progress: undefined,
}
getTaskMap(manager).set(task.id, task)
//#when — only 5 min since start, within 10min timeout
await manager["checkAndInterruptStaleTasks"]({})
//#then — task survives
expect(task.status).toBe("running")
})
})
describe("BackgroundManager.shutdown session abort", () => {
@@ -2778,43 +2519,6 @@ 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", () => {
@@ -2862,35 +2566,6 @@ 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()
@@ -3036,32 +2711,13 @@ 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(() => {
@@ -3546,134 +3202,4 @@ describe("BackgroundManager.handleEvent - non-tool event lastUpdate", () => {
//#then - task should still be running (text event refreshed lastUpdate)
expect(task.status).toBe("running")
})
test("should refresh lastUpdate on message.part.delta events (OpenCode >=1.2.0)", async () => {
//#given - a running task with stale lastUpdate
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
stubNotifyParentSession(manager)
const task: BackgroundTask = {
id: "task-delta-1",
sessionID: "session-delta-1",
parentSessionID: "parent-1",
parentMessageID: "msg-1",
description: "Reasoning task with delta events",
prompt: "Extended thinking",
agent: "oracle",
status: "running",
startedAt: new Date(Date.now() - 600_000),
progress: {
toolCalls: 0,
lastUpdate: new Date(Date.now() - 300_000),
},
}
getTaskMap(manager).set(task.id, task)
//#when - a message.part.delta event arrives (reasoning-delta or text-delta in OpenCode >=1.2.0)
manager.handleEvent({
type: "message.part.delta",
properties: { sessionID: "session-delta-1", field: "text", delta: "thinking..." },
})
await manager["checkAndInterruptStaleTasks"]()
//#then - task should still be running (delta event refreshed lastUpdate)
expect(task.status).toBe("running")
})
})
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

@@ -12,7 +12,6 @@ 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,
@@ -528,12 +527,6 @@ 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)
@@ -666,7 +659,7 @@ export class BackgroundManager {
handleEvent(event: Event): void {
const props = event.properties
if (event.type === "message.part.updated" || event.type === "message.part.delta") {
if (event.type === "message.part.updated") {
if (!props || typeof props !== "object" || !("sessionID" in props)) return
const partInfo = props as unknown as MessagePartInfo
const sessionID = partInfo?.sessionID
@@ -789,10 +782,6 @@ 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)
}
@@ -840,10 +829,6 @@ 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)
}
@@ -1014,10 +999,6 @@ 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
}
@@ -1257,10 +1238,11 @@ 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 while loading messages; using messageDir fallback:", {
log("[background-agent] Parent session aborted, skipping notification:", {
taskId: task.id,
parentSessionID: task.parentSessionID,
})
return
}
const messageDir = getMessageDir(task.parentSessionID)
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
@@ -1294,13 +1276,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 while sending notification; continuing cleanup:", {
log("[background-agent] Parent session aborted, skipping notification:", {
taskId: task.id,
parentSessionID: task.parentSessionID,
})
} else {
log("[background-agent] Failed to send notification:", error)
return
}
log("[background-agent] Failed to send notification:", error)
}
if (allComplete) {
@@ -1430,10 +1412,6 @@ 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)
@@ -1459,55 +1437,24 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
}
}
private async checkAndInterruptStaleTasks(
allStatuses: Record<string, { type: string }> = {},
): Promise<void> {
private async checkAndInterruptStaleTasks(): Promise<void> {
const staleTimeoutMs = this.config?.staleTimeoutMs ?? DEFAULT_STALE_TIMEOUT_MS
const messageStalenessMs = this.config?.messageStalenessTimeoutMs ?? DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS
const now = Date.now()
for (const task of this.tasks.values()) {
if (task.status !== "running") continue
if (!task.progress?.lastUpdate) continue
const startedAt = task.startedAt
const sessionID = task.sessionID
if (!startedAt || !sessionID) continue
const sessionStatus = allStatuses[sessionID]?.type
const sessionIsRunning = sessionStatus !== undefined && sessionStatus !== "idle"
const runtime = now - startedAt.getTime()
if (!task.progress?.lastUpdate) {
if (sessionIsRunning) continue
if (runtime <= messageStalenessMs) continue
const staleMinutes = Math.round(runtime / 60000)
task.status = "cancelled"
task.error = `Stale timeout (no activity for ${staleMinutes}min since start)`
task.completedAt = new Date()
if (task.concurrencyKey) {
this.concurrencyManager.release(task.concurrencyKey)
task.concurrencyKey = undefined
}
this.client.session.abort({ path: { id: sessionID } }).catch(() => {})
log(`[background-agent] Task ${task.id} interrupted: no progress since start`)
try {
await this.enqueueNotificationForParent(task.parentSessionID, () => this.notifyParentSession(task))
} catch (err) {
log("[background-agent] Error in notifyParentSession for stale task:", { taskId: task.id, error: err })
}
continue
}
if (sessionIsRunning) continue
if (runtime < MIN_RUNTIME_BEFORE_STALE_MS) continue
const timeSinceLastUpdate = now - task.progress.lastUpdate.getTime()
if (timeSinceLastUpdate <= staleTimeoutMs) continue
if (task.status !== "running") continue
const staleMinutes = Math.round(timeSinceLastUpdate / 60000)
@@ -1520,7 +1467,10 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
task.concurrencyKey = undefined
}
this.client.session.abort({ path: { id: sessionID } }).catch(() => {})
this.client.session.abort({
path: { id: sessionID },
}).catch(() => {})
log(`[background-agent] Task ${task.id} interrupted: stale timeout`)
try {
@@ -1533,12 +1483,11 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
private async pollRunningTasks(): Promise<void> {
this.pruneStaleTasksAndNotifications()
await this.checkAndInterruptStaleTasks()
const statusResult = await this.client.session.status()
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>
await this.checkAndInterruptStaleTasks(allStatuses)
for (const task of this.tasks.values()) {
if (task.status !== "running") continue
@@ -1548,6 +1497,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
try {
const sessionStatus = allStatuses[sessionID]
// Don't skip if session not in status - fall through to message-based detection
if (sessionStatus?.type === "idle") {
// Edge guard: Validate session has actual output before completing
const hasValidOutput = await this.validateSessionHasOutput(sessionID)

View File

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

View File

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

View File

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

View File

@@ -6,15 +6,20 @@ import { tmpdir } from "os"
const TEST_DIR = join(tmpdir(), "mcp-loader-test-" + Date.now())
const TEST_HOME = join(TEST_DIR, "home")
const realOs = await import("os")
const realShared = await import("../../shared")
describe("getSystemMcpServerNames", () => {
beforeEach(() => {
mkdirSync(TEST_DIR, { recursive: true })
mkdirSync(TEST_HOME, { recursive: true })
mock.module("os", () => ({
...realOs,
homedir: () => TEST_HOME,
tmpdir,
}))
mock.module("../../shared", () => ({
...realShared,
getClaudeConfigDir: () => join(TEST_HOME, ".claude"),
}))
})
@@ -22,6 +27,9 @@ describe("getSystemMcpServerNames", () => {
afterEach(() => {
mock.restore()
rmSync(TEST_DIR, { recursive: true, force: true })
mock.module("os", () => ({ ...realOs }))
mock.module("../../shared", () => ({ ...realShared }))
})
it("returns empty set when no .mcp.json files exist", async () => {

View File

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

View File

@@ -53,26 +53,28 @@ async function loadSourcePath(options: {
const stat = await fs.stat(absolutePath).catch(() => null)
if (!stat) return []
const realBasePath = await fs.realpath(absolutePath).catch(() => absolutePath)
if (stat.isFile()) {
if (!isMarkdownPath(absolutePath)) return []
if (!isMarkdownPath(realBasePath)) return []
const loaded = await loadSkillFromPath({
skillPath: absolutePath,
resolvedPath: dirname(absolutePath),
defaultName: inferSkillNameFromFileName(absolutePath),
skillPath: realBasePath,
resolvedPath: dirname(realBasePath),
defaultName: inferSkillNameFromFileName(realBasePath),
scope: "config",
})
if (!loaded) return []
return filterByGlob([loaded], dirname(absolutePath), options.globPattern)
return filterByGlob([loaded], dirname(realBasePath), options.globPattern)
}
if (!stat.isDirectory()) return []
const directorySkills = await loadSkillsFromDir({
skillsDir: absolutePath,
skillsDir: realBasePath,
scope: "config",
maxDepth: options.recursive ? MAX_RECURSIVE_DEPTH : 0,
})
return filterByGlob(directorySkills, absolutePath, options.globPattern)
return filterByGlob(directorySkills, realBasePath, options.globPattern)
}
export async function discoverConfigSourceSkills(options: {

View File

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

View File

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

View File

@@ -1,8 +1,13 @@
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test"
import { describe, it, expect, beforeEach, afterEach, mock, spyOn, afterAll } from "bun:test"
import { SkillMcpManager } from "./manager"
import type { SkillMcpClientInfo, SkillMcpServerContext } from "./types"
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
const realStreamableHttp = await import(
"@modelcontextprotocol/sdk/client/streamableHttp.js"
)
const realMcpOauthProvider = await import("../mcp-oauth/provider")
// Mock the MCP SDK transports to avoid network calls
const mockHttpConnect = mock(() => Promise.reject(new Error("Mocked HTTP connection failure")))
const mockHttpClose = mock(() => Promise.resolve())
@@ -37,6 +42,13 @@ mock.module("../mcp-oauth/provider", () => ({
},
}))
afterAll(() => {
mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
...realStreamableHttp,
}))
mock.module("../mcp-oauth/provider", () => ({ ...realMcpOauthProvider }))
})

View File

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

View File

@@ -1,10 +1,10 @@
import { MIN_PANE_HEIGHT, MIN_PANE_WIDTH } from "./types"
import type { TmuxPaneInfo } from "./types"
import {
DIVIDER_SIZE,
MAIN_PANE_RATIO,
MAX_GRID_SIZE,
} from "./tmux-grid-constants"
import { MIN_PANE_HEIGHT, MIN_PANE_WIDTH } from "./types"
export interface GridCapacity {
cols: number
@@ -27,7 +27,6 @@ export interface GridPlan {
export function calculateCapacity(
windowWidth: number,
windowHeight: number,
minPaneWidth: number = MIN_PANE_WIDTH,
): GridCapacity {
const availableWidth = Math.floor(windowWidth * (1 - MAIN_PANE_RATIO))
const cols = Math.min(
@@ -35,7 +34,7 @@ export function calculateCapacity(
Math.max(
0,
Math.floor(
(availableWidth + DIVIDER_SIZE) / (minPaneWidth + DIVIDER_SIZE),
(availableWidth + DIVIDER_SIZE) / (MIN_PANE_WIDTH + DIVIDER_SIZE),
),
),
)

View File

@@ -1,9 +1,13 @@
import { describe, test, expect, mock, beforeEach } from 'bun:test'
import { describe, test, expect, mock, beforeEach, afterAll } from 'bun:test'
import type { TmuxConfig } from '../../config/schema'
import type { WindowState, PaneAction } from './types'
import type { ActionResult, ExecuteContext } from './action-executor'
import type { TmuxUtilDeps } from './manager'
const realPaneStateQuerier = await import('./pane-state-querier')
const realActionExecutor = await import('./action-executor')
const realSharedTmux = await import('../../shared/tmux')
type ExecuteActionsResult = {
success: boolean
spawnedPaneId?: string
@@ -71,6 +75,12 @@ mock.module('../../shared/tmux', () => {
}
})
afterAll(() => {
mock.module('./pane-state-querier', () => ({ ...realPaneStateQuerier }))
mock.module('./action-executor', () => ({ ...realActionExecutor }))
mock.module('../../shared/tmux', () => ({ ...realSharedTmux }))
})
const trackedSessions = new Set<string>()
function createMockContext(overrides?: {

View File

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

View File

@@ -13,23 +13,23 @@ import {
} from "./pane-split-availability"
import { findSpawnTarget } from "./spawn-target-finder"
import { findOldestAgentPane, type SessionMapping } from "./oldest-agent-pane"
import { MIN_PANE_WIDTH } from "./types"
export function decideSpawnActions(
state: WindowState,
sessionId: string,
description: string,
config: CapacityConfig,
_config: CapacityConfig,
sessionMappings: SessionMapping[],
): SpawnDecision {
if (!state.mainPane) {
return { canSpawn: false, actions: [], reason: "no main pane found" }
}
const minPaneWidth = config.agentPaneWidth
const agentAreaWidth = Math.floor(state.windowWidth * (1 - MAIN_PANE_RATIO))
const currentCount = state.agentPanes.length
if (agentAreaWidth < minPaneWidth) {
if (agentAreaWidth < MIN_PANE_WIDTH) {
return {
canSpawn: false,
actions: [],
@@ -44,7 +44,7 @@ export function decideSpawnActions(
if (currentCount === 0) {
const virtualMainPane: TmuxPaneInfo = { ...state.mainPane, width: state.windowWidth }
if (canSplitPane(virtualMainPane, "-h", minPaneWidth)) {
if (canSplitPane(virtualMainPane, "-h")) {
return {
canSpawn: true,
actions: [
@@ -61,7 +61,7 @@ export function decideSpawnActions(
return { canSpawn: false, actions: [], reason: "mainPane too small to split" }
}
if (isSplittableAtCount(agentAreaWidth, currentCount, minPaneWidth)) {
if (isSplittableAtCount(agentAreaWidth, currentCount)) {
const spawnTarget = findSpawnTarget(state)
if (spawnTarget) {
return {
@@ -79,7 +79,7 @@ export function decideSpawnActions(
}
}
const minEvictions = findMinimalEvictions(agentAreaWidth, currentCount, minPaneWidth)
const minEvictions = findMinimalEvictions(agentAreaWidth, currentCount)
if (minEvictions === 1 && oldestPane) {
return {
canSpawn: true,

View File

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

View File

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

View File

@@ -1,13 +1,21 @@
import { describe, test, expect, mock, beforeEach } from "bun:test"
import { describe, test, expect, mock, beforeEach, afterAll } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import type { ExperimentalConfig } from "../../config"
const attemptDeduplicationRecoveryMock = mock(async () => {})
const realDeduplicationRecovery = await import("./deduplication-recovery")
const attemptDeduplicationRecoveryMock = mock<(sessionID: string) => Promise<void>>(
async () => {}
)
mock.module("./deduplication-recovery", () => ({
attemptDeduplicationRecovery: attemptDeduplicationRecoveryMock,
}))
afterAll(() => {
mock.module("./deduplication-recovery", () => ({ ...realDeduplicationRecovery }))
})
function createImmediateTimeouts(): () => void {
const originalSetTimeout = globalThis.setTimeout
const originalClearTimeout = globalThis.clearTimeout
@@ -37,13 +45,15 @@ describe("createAnthropicContextWindowLimitRecoveryHook", () => {
const experimental = {
dynamic_context_pruning: {
enabled: true,
notification: "off",
protected_tools: [],
strategies: {
deduplication: { enabled: true },
},
},
} satisfies ExperimentalConfig
let resolveSummarize: (() => void) | null = null
let resolveSummarize: ((value?: void) => void) | null = null
const summarizePromise = new Promise<void>((resolve) => {
resolveSummarize = resolve
})
@@ -62,7 +72,7 @@ describe("createAnthropicContextWindowLimitRecoveryHook", () => {
try {
const { createAnthropicContextWindowLimitRecoveryHook } = await import("./recovery-hook")
const ctx = { client: mockClient, directory: "/tmp" } as PluginInput
const ctx = { client: mockClient, directory: "/tmp" } as unknown as PluginInput
const hook = createAnthropicContextWindowLimitRecoveryHook(ctx, { experimental })
// first error triggers compaction (setTimeout runs immediately due to mock)
@@ -105,7 +115,7 @@ describe("createAnthropicContextWindowLimitRecoveryHook", () => {
}
const { createAnthropicContextWindowLimitRecoveryHook } = await import("./recovery-hook")
const ctx = { client: mockClient, directory: "/tmp" } as PluginInput
const ctx = { client: mockClient, directory: "/tmp" } as unknown as PluginInput
const hook = createAnthropicContextWindowLimitRecoveryHook(ctx)
//#when - single error (no compaction in progress)

View File

@@ -1,105 +0,0 @@
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,7 +28,6 @@ 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
@@ -36,12 +35,6 @@ 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)
@@ -83,8 +76,7 @@ export function createAnthropicContextWindowLimitRecoveryHook(
})
.catch(() => {})
const timeoutID = setTimeout(() => {
pendingCompactionTimeoutBySession.delete(sessionID)
setTimeout(() => {
executeCompact(
sessionID,
{ providerID, modelID },
@@ -94,8 +86,6 @@ export function createAnthropicContextWindowLimitRecoveryHook(
experimental,
)
}, 300)
pendingCompactionTimeoutBySession.set(sessionID, timeoutID)
}
return
}
@@ -124,12 +114,6 @@ 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

@@ -1,4 +1,4 @@
import { describe, test, expect, mock, beforeEach } from "bun:test"
import { describe, test, expect, mock, beforeEach, afterAll } from "bun:test"
import { truncateUntilTargetTokens } from "./storage"
import * as storage from "./storage"
@@ -11,6 +11,10 @@ mock.module("./storage", () => {
}
})
afterAll(() => {
mock.module("./storage", () => ({ ...storage }))
})
describe("truncateUntilTargetTokens", () => {
const sessionID = "test-session"

View File

@@ -1,4 +1,4 @@
import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"
import { describe, expect, test, beforeEach, afterEach, afterAll, mock } from "bun:test"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
@@ -9,10 +9,18 @@ import {
readBoulderState,
} from "../../features/boulder-state"
import type { BoulderState } from "../../features/boulder-state"
const realClaudeCodeSessionState = await import(
"../../features/claude-code-session-state"
)
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
import { _resetForTesting, subagentSessions } from "../../features/claude-code-session-state"
import { createAtlasHook } from "./index"
const { createAtlasHook } = await import("./index")
const { MESSAGE_STORAGE } = await import("../../features/hook-message-injector")
afterAll(() => {
mock.module("../../features/claude-code-session-state", () => ({
...realClaudeCodeSessionState,
}))
})
describe("atlas hook", () => {
let TEST_DIR: string
@@ -621,14 +629,15 @@ describe("atlas hook", () => {
}
beforeEach(() => {
_resetForTesting()
subagentSessions.clear()
mock.module("../../features/claude-code-session-state", () => ({
getMainSessionID: () => MAIN_SESSION_ID,
subagentSessions: new Set<string>(),
}))
setupMessageStorage(MAIN_SESSION_ID, "atlas")
})
afterEach(() => {
cleanupMessageStorage(MAIN_SESSION_ID)
_resetForTesting()
})
test("should inject continuation when boulder has incomplete tasks", async () => {

View File

@@ -1,4 +1,11 @@
import { describe, it, expect, mock, beforeEach } from "bun:test"
import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test"
const realChecker = await import("../checker")
const realVersionChannel = await import("../version-channel")
const realCache = await import("../cache")
const realConfigManager = await import("../../../cli/config-manager")
const realUpdateToasts = await import("./update-toasts")
const realLogger = await import("../../../shared/logger")
// Mock modules before importing
const mockFindPluginEntry = mock(() => null as any)
@@ -39,6 +46,15 @@ mock.module("../../../shared/logger", () => ({
log: () => {},
}))
afterAll(() => {
mock.module("../checker", () => ({ ...realChecker }))
mock.module("../version-channel", () => ({ ...realVersionChannel }))
mock.module("../cache", () => ({ ...realCache }))
mock.module("../../../cli/config-manager", () => ({ ...realConfigManager }))
mock.module("./update-toasts", () => ({ ...realUpdateToasts }))
mock.module("../../../shared/logger", () => ({ ...realLogger }))
})
const { runBackgroundUpdateCheck } = await import("./background-update-check")
describe("runBackgroundUpdateCheck", () => {

View File

@@ -1,7 +1,10 @@
import { describe, it, expect, mock, beforeEach } from "bun:test"
import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test"
import type { ClaudeHooksConfig } from "./types"
import type { StopContext } from "./stop"
const realCommandExecutor = await import("../../shared/command-executor")
const realLogger = await import("../../shared/logger")
const mockExecuteHookCommand = mock(() =>
Promise.resolve({ exitCode: 0, stdout: "", stderr: "" })
)
@@ -17,6 +20,11 @@ mock.module("../../shared/logger", () => ({
getLogFilePath: () => "/tmp/test.log",
}))
afterAll(() => {
mock.module("../../shared/command-executor", () => ({ ...realCommandExecutor }))
mock.module("../../shared/logger", () => ({ ...realLogger }))
})
const { executeStopHooks } = await import("./stop")
function createStopContext(overrides?: Partial<StopContext>): StopContext {

View File

@@ -1,10 +1,19 @@
import { describe, test, expect, mock } from "bun:test"
import { describe, test, expect, mock, afterAll } from "bun:test"
import { chmodSync, mkdtempSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import type { PendingCall } from "./types"
const realCli = await import("./cli")
const cliTsHref = new URL("./cli.ts", import.meta.url).href
afterAll(() => {
mock.module("./cli", () => ({ ...realCli }))
mock.module("./cli.ts", () => ({ ...realCli }))
mock.module(cliTsHref, () => ({ ...realCli }))
})
function createMockInput() {
return {
session_id: "test",

View File

@@ -1,4 +1,6 @@
import { describe, it, expect, mock, beforeEach } from "bun:test"
import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test"
const realCliRunner = await import("./cli-runner")
const processApplyPatchEditsWithCli = mock(async () => {})
@@ -10,6 +12,10 @@ mock.module("./cli-runner", () => ({
processApplyPatchEditsWithCli,
}))
afterAll(() => {
mock.module("./cli-runner", () => ({ ...realCliRunner }))
})
const { createCommentCheckerHooks } = await import("./hook")
describe("comment-checker apply_patch integration", () => {

View File

@@ -1,4 +1,6 @@
import { describe, expect, it, mock } from "bun:test"
import { describe, expect, it, mock, afterAll } from "bun:test"
const realSystemDirective = await import("../../shared/system-directive")
mock.module("../../shared/system-directive", () => ({
createSystemDirective: (type: string) => `[DIRECTIVE:${type}]`,
@@ -14,6 +16,10 @@ mock.module("../../shared/system-directive", () => ({
},
}))
afterAll(() => {
mock.module("../../shared/system-directive", () => ({ ...realSystemDirective }))
})
import { createCompactionContextInjector } from "./index"
import { TaskHistory } from "../../features/background-agent/task-history"

View File

@@ -30,7 +30,7 @@ function createMockContext(todoResponses: TodoSnapshot[][]): PluginInput {
},
},
directory: "/tmp/test",
} as PluginInput
} as unknown as PluginInput
}
describe("compaction-todo-preserver", () => {
@@ -38,7 +38,7 @@ describe("compaction-todo-preserver", () => {
//#given
updateMock.mockClear()
const sessionID = "session-compaction-missing"
const todos = [
const todos: TodoSnapshot[] = [
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
{ id: "2", content: "Task 2", status: "in_progress", priority: "medium" },
]
@@ -58,7 +58,7 @@ describe("compaction-todo-preserver", () => {
//#given
updateMock.mockClear()
const sessionID = "session-compaction-present"
const todos = [
const todos: TodoSnapshot[] = [
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
]
const ctx = createMockContext([todos, todos])

View File

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

View File

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

View File

@@ -1,12 +1,5 @@
import { describe, it, expect, mock, beforeEach } from "bun:test"
const logMock = mock(() => {})
mock.module("../shared/logger", () => ({
log: logMock,
}))
const { createPreemptiveCompactionHook } = await import("./preemptive-compaction")
import { createPreemptiveCompactionHook } from "./preemptive-compaction"
function createMockCtx() {
return {
@@ -28,7 +21,6 @@ describe("preemptive-compaction", () => {
beforeEach(() => {
ctx = createMockCtx()
logMock.mockClear()
})
// #given event caches token info from message.updated
@@ -160,45 +152,4 @@ 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,5 +1,3 @@
import { log } from "../shared/logger"
const DEFAULT_ACTUAL_LIMIT = 200_000
const ANTHROPIC_ACTUAL_LIMIT =
@@ -78,8 +76,8 @@ export function createPreemptiveCompactionHook(ctx: PluginInput) {
})
compactedSessions.add(sessionID)
} catch (error) {
log("[preemptive-compaction] Compaction failed", { sessionID, error: String(error) })
} catch {
// best-effort; do not disrupt tool execution
} finally {
compactionInProgress.delete(sessionID)
}

View File

@@ -6,8 +6,8 @@ import { randomUUID } from "node:crypto"
import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive"
import { clearSessionAgent } from "../../features/claude-code-session-state"
import { createPrometheusMdOnlyHook } from "./index"
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
const { createPrometheusMdOnlyHook } = await import("./index")
const { MESSAGE_STORAGE } = await import("../../features/hook-message-injector")
describe("prometheus-md-only", () => {
const TEST_SESSION_ID = "test-session-prometheus"

View File

@@ -1,4 +1,4 @@
import { afterAll, afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
import { afterEach, beforeEach, describe, expect, it, mock, afterAll } from "bun:test";
import * as fs from "node:fs";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import * as os from "node:os";
@@ -6,6 +6,10 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { RULES_INJECTOR_STORAGE } from "./constants";
const realNodeFs = await import("node:fs");
const realNodeOs = await import("node:os");
const realMatcher = await import("./matcher");
type StatSnapshot = { mtimeMs: number; size: number };
let trackedRulePath = "";
@@ -56,6 +60,12 @@ mock.module("./matcher", () => ({
isDuplicateByContentHash: (hash: string, cache: Set<string>) => cache.has(hash),
}));
afterAll(() => {
mock.module("node:fs", () => ({ ...realNodeFs }));
mock.module("node:os", () => ({ ...realNodeOs }));
mock.module("./matcher", () => ({ ...realMatcher }));
});
function createOutput(): { title: string; output: string; metadata: unknown } {
return { title: "tool", output: "", metadata: {} };
}
@@ -102,10 +112,6 @@ function getInjectedRulesPath(sessionID: string): string {
}
describe("createRuleInjectionProcessor", () => {
afterAll(() => {
mock.restore();
});
let testRoot: string;
let projectRoot: string;
let homeRoot: string;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,5 +18,3 @@ 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,14 +141,11 @@ ${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,9 +8,7 @@ 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"
@@ -101,35 +99,8 @@ export async function handleSessionIdle(args: {
return
}
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,
})
if (state.lastInjectedAt && Date.now() - state.lastInjectedAt < CONTINUATION_COOLDOWN_MS) {
log(`[${HOOK_NAME}] Skipped: cooldown active`, { sessionID })
return
}

View File

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

View File

@@ -1,26 +1,18 @@
/// <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,
FAILURE_RESET_WINDOW_MS,
MAX_CONSECUTIVE_FAILURES,
} from "./constants"
import { CONTINUATION_COOLDOWN_MS } 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
@@ -60,41 +52,20 @@ function createFakeTimers(): FakeTimers {
}
globalThis.setTimeout = ((callback: TimerCallback, delay?: number, ...args: any[]) => {
const normalized = normalizeDelay(delay)
if (normalized < FAKE_MIN_DELAY_MS) {
return original.setTimeout(callback, delay, ...args)
}
if (normalized >= REAL_MAX_DELAY_MS) {
return original.setTimeout(callback, delay, ...args)
}
return schedule(callback, normalized, null, args) as unknown as ReturnType<typeof setTimeout>
return schedule(callback, delay, null, args) as unknown as ReturnType<typeof setTimeout>
}) as typeof setTimeout
globalThis.setInterval = ((callback: TimerCallback, delay?: number, ...args: any[]) => {
const interval = normalizeDelay(delay)
if (interval < FAKE_MIN_DELAY_MS) {
return original.setInterval(callback, delay, ...args)
}
if (interval >= REAL_MAX_DELAY_MS) {
return original.setInterval(callback, delay, ...args)
}
return schedule(callback, interval, interval, args) as unknown as ReturnType<typeof setInterval>
return schedule(callback, delay, interval, args) as unknown as ReturnType<typeof setInterval>
}) as typeof setInterval
globalThis.clearTimeout = ((id?: Parameters<typeof clearTimeout>[0]) => {
if (typeof id === "number" && timers.has(id)) {
clear(id)
return
}
original.clearTimeout(id)
globalThis.clearTimeout = ((id?: number) => {
clear(id)
}) as typeof clearTimeout
globalThis.clearInterval = ((id?: Parameters<typeof clearInterval>[0]) => {
if (typeof id === "number" && timers.has(id)) {
clear(id)
return
}
original.clearInterval(id)
globalThis.clearInterval = ((id?: number) => {
clear(id)
}) as typeof clearInterval
Date.now = () => clockNow
@@ -136,12 +107,6 @@ function createFakeTimers(): FakeTimers {
await Promise.resolve()
}
const advanceClockBy = async (ms: number) => {
const clamped = Math.max(0, ms)
clockNow += clamped
await Promise.resolve()
}
const restore = () => {
globalThis.setTimeout = original.setTimeout
globalThis.clearTimeout = original.clearTimeout
@@ -150,7 +115,7 @@ function createFakeTimers(): FakeTimers {
Date.now = original.dateNow
}
return { advanceBy, advanceClockBy, restore }
return { advanceBy, restore }
}
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
@@ -168,15 +133,6 @@ 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() {
@@ -554,7 +510,7 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(2500, true)
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true)
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
@@ -562,165 +518,7 @@ 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 })
}, 20000)
test("should keep injecting even when todos remain unchanged across cycles", async () => {
//#given
@@ -736,26 +534,26 @@ describe("todo-continuation-enforcer", () => {
//#when — 5 consecutive idle cycles with unchanged todos
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true)
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true)
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true)
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true)
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
//#then — all 5 injections should fire (no stagnation cap)
expect(promptCalls).toHaveLength(5)
}, { timeout: 60000 })
}, 30000)
test("should skip idle handling while injection is in flight", async () => {
//#given
@@ -815,7 +613,7 @@ describe("todo-continuation-enforcer", () => {
//#then
expect(promptCalls).toHaveLength(2)
}, { timeout: 15000 })
}, 20000)
test("should accept skipAgents option without error", async () => {
// given - session with skipAgents configured for Prometheus

View File

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

View File

@@ -1,118 +0,0 @@
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,14 +56,12 @@ export function createChatMessageHandler(args: {
const message = output.message
if (firstMessageVariantGate.shouldOverride(input.sessionID)) {
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
}
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,8 +12,6 @@ import {
discoverProjectClaudeSkills,
discoverOpencodeGlobalSkills,
discoverOpencodeProjectSkills,
discoverProjectAgentsSkills,
discoverGlobalAgentsSkills,
mergeSkills,
} from "../features/opencode-skill-loader"
import { createBuiltinSkills } from "../features/builtin-skills"
@@ -57,7 +55,7 @@ export async function createSkillContext(args: {
})
const includeClaudeSkills = pluginConfig.claude_code?.skills !== false
const [configSourceSkills, userSkills, globalSkills, projectSkills, opencodeProjectSkills, agentsProjectSkills, agentsGlobalSkills] =
const [configSourceSkills, userSkills, globalSkills, projectSkills, opencodeProjectSkills] =
await Promise.all([
discoverConfigSourceSkills({
config: pluginConfig.skills,
@@ -67,17 +65,15 @@ export async function createSkillContext(args: {
discoverOpencodeGlobalSkills(),
includeClaudeSkills ? discoverProjectClaudeSkills(directory) : Promise.resolve([]),
discoverOpencodeProjectSkills(directory),
discoverProjectAgentsSkills(directory),
discoverGlobalAgentsSkills(),
])
const mergedSkills = mergeSkills(
builtinSkills,
pluginConfig.skills,
configSourceSkills,
[...userSkills, ...agentsGlobalSkills],
userSkills,
globalSkills,
[...projectSkills, ...agentsProjectSkills],
projectSkills,
opencodeProjectSkills,
{ configDir: directory },
)

View File

@@ -0,0 +1,14 @@
import { describe, test, expect } from "bun:test"
import { getAgentsConfigDir } from "./agents-config-dir"
describe("getAgentsConfigDir", () => {
test("returns path ending with .agents", () => {
// given agents config dir is requested
// when getAgentsConfigDir is called
const result = getAgentsConfigDir()
// then returns path ending with .agents
expect(result.endsWith(".agents")).toBe(true)
})
})

View File

@@ -0,0 +1,6 @@
import { homedir } from "node:os"
import { join } from "node:path"
export function getAgentsConfigDir(): string {
return join(homedir(), ".agents")
}

View File

@@ -1,10 +1,10 @@
import { describe, it, expect, beforeAll, afterAll } from "bun:test"
import { mkdirSync, writeFileSync, symlinkSync, rmSync } from "fs"
import { mkdirSync, writeFileSync, symlinkSync, rmSync, realpathSync } 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())
const testDir = join(realpathSync(tmpdir()), "file-utils-test-" + Date.now())
// Create a directory structure that mimics the real-world scenario:
//

View File

@@ -1,10 +1,6 @@
import { lstatSync, realpathSync } from "fs"
import { promises as fs } from "fs"
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()
}
@@ -19,7 +15,7 @@ export function isSymbolicLink(filePath: string): boolean {
export function resolveSymlink(filePath: string): string {
try {
return normalizeDarwinRealpath(realpathSync(filePath))
return realpathSync(filePath)
} catch {
return filePath
}
@@ -27,7 +23,7 @@ export function resolveSymlink(filePath: string): string {
export async function resolveSymlinkAsync(filePath: string): Promise<string> {
try {
return normalizeDarwinRealpath(await fs.realpath(filePath))
return await fs.realpath(filePath)
} catch {
return filePath
}

View File

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

View File

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

View File

@@ -1,16 +1,34 @@
const { describe, test, expect, mock } = require("bun:test")
const { describe, test, expect, mock, beforeEach, afterEach } = require("bun:test")
const realCompletionPoller = require("./completion-poller")
const realMessageProcessor = require("./message-processor")
const waitForCompletionMock = mock(async () => {})
const processMessagesMock = mock(async () => "agent response")
beforeEach(() => {
waitForCompletionMock.mockClear()
processMessagesMock.mockClear()
mock.module("./completion-poller", () => ({
waitForCompletion: waitForCompletionMock,
}))
mock.module("./message-processor", () => ({
processMessages: processMessagesMock,
}))
})
afterEach(() => {
mock.module("./completion-poller", () => ({ ...realCompletionPoller }))
mock.module("./message-processor", () => ({ ...realMessageProcessor }))
})
describe("executeSync", () => {
test("passes question=false via tools parameter to block question tool", async () => {
//#given
const { executeSync } = require("./sync-executor")
const deps = {
createOrGetSession: mock(async () => ({ sessionID: "ses-test-123", isNew: true })),
waitForCompletion: mock(async () => {}),
processMessages: mock(async () => "agent response"),
}
let promptArgs: any
const promptAsync = mock(async (input: any) => {
promptArgs = input
@@ -21,6 +39,7 @@ describe("executeSync", () => {
subagent_type: "explore",
description: "test task",
prompt: "find something",
session_id: "ses-test-123",
}
const toolContext = {
@@ -33,12 +52,15 @@ describe("executeSync", () => {
const ctx = {
client: {
session: { promptAsync },
session: {
promptAsync,
get: mock(async () => ({ data: { id: "ses-test-123" } })),
},
},
}
//#when
await executeSync(args, toolContext, ctx as any, deps)
await executeSync(args, toolContext, ctx as any)
//#then
expect(promptAsync).toHaveBeenCalled()
@@ -49,12 +71,6 @@ describe("executeSync", () => {
//#given
const { executeSync } = require("./sync-executor")
const deps = {
createOrGetSession: mock(async () => ({ sessionID: "ses-test-123", isNew: true })),
waitForCompletion: mock(async () => {}),
processMessages: mock(async () => "agent response"),
}
let promptArgs: any
const promptAsync = mock(async (input: any) => {
promptArgs = input
@@ -65,6 +81,7 @@ describe("executeSync", () => {
subagent_type: "librarian",
description: "search docs",
prompt: "find docs",
session_id: "ses-test-123",
}
const toolContext = {
@@ -77,12 +94,15 @@ describe("executeSync", () => {
const ctx = {
client: {
session: { promptAsync },
session: {
promptAsync,
get: mock(async () => ({ data: { id: "ses-test-123" } })),
},
},
}
//#when
await executeSync(args, toolContext, ctx as any, deps)
await executeSync(args, toolContext, ctx as any)
//#then
expect(promptAsync).toHaveBeenCalled()

View File

@@ -6,18 +6,6 @@ import { createOrGetSession } from "./session-creator"
import { waitForCompletion } from "./completion-poller"
import { processMessages } from "./message-processor"
type ExecuteSyncDeps = {
createOrGetSession: typeof createOrGetSession
waitForCompletion: typeof waitForCompletion
processMessages: typeof processMessages
}
const defaultDeps: ExecuteSyncDeps = {
createOrGetSession,
waitForCompletion,
processMessages,
}
export async function executeSync(
args: CallOmoAgentArgs,
toolContext: {
@@ -27,10 +15,9 @@ export async function executeSync(
abort: AbortSignal
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void
},
ctx: PluginInput,
deps: ExecuteSyncDeps = defaultDeps
ctx: PluginInput
): Promise<string> {
const { sessionID } = await deps.createOrGetSession(args, toolContext, ctx)
const { sessionID } = await createOrGetSession(args, toolContext, ctx)
await toolContext.metadata?.({
title: args.description,
@@ -62,9 +49,9 @@ export async function executeSync(
return `Error: Failed to send prompt: ${errorMessage}\n\n<task_metadata>\nsession_id: ${sessionID}\n</task_metadata>`
}
await deps.waitForCompletion(sessionID, toolContext, ctx)
await waitForCompletion(sessionID, toolContext, ctx)
const responseText = await deps.processMessages(sessionID, ctx)
const responseText = await processMessages(sessionID, ctx)
const output =
responseText + "\n\n" + ["<task_metadata>", `session_id: ${sessionID}`, "</task_metadata>"].join("\n")

View File

@@ -162,16 +162,6 @@ Approach:
- Draft with care
- Polish for clarity and impact
- Documentation, READMEs, articles, technical writing
ANTI-AI-SLOP RULES (NON-NEGOTIABLE):
- NEVER use em dashes (—) or en dashes (). Use commas, periods, ellipses, or line breaks instead. Zero tolerance.
- Remove AI-sounding phrases: "delve", "it's important to note", "I'd be happy to", "certainly", "please don't hesitate", "leverage", "utilize", "in order to", "moving forward", "circle back", "at the end of the day", "robust", "streamline", "facilitate"
- Pick plain words. "Use" not "utilize". "Start" not "commence". "Help" not "facilitate".
- Use contractions naturally: "don't" not "do not", "it's" not "it is".
- Vary sentence length. Don't make every sentence the same length.
- NEVER start consecutive sentences with the same word.
- No filler openings: skip "In today's world...", "As we all know...", "It goes without saying..."
- Write like a human, not a corporate template.
</Category_Context>`
export const DEEP_CATEGORY_PROMPT_APPEND = `<Category_Context>
@@ -208,14 +198,14 @@ You are NOT an interactive assistant. You are an autonomous problem-solver.
export const DEFAULT_CATEGORIES: Record<string, CategoryConfig> = {
"visual-engineering": { model: "google/gemini-3-pro", variant: "high" },
"visual-engineering": { model: "google/gemini-3-pro" },
ultrabrain: { model: "openai/gpt-5.3-codex", variant: "xhigh" },
deep: { model: "openai/gpt-5.3-codex", variant: "medium" },
artistry: { model: "google/gemini-3-pro", variant: "high" },
quick: { model: "anthropic/claude-haiku-4-5" },
"unspecified-low": { model: "anthropic/claude-sonnet-4-5" },
"unspecified-high": { model: "anthropic/claude-opus-4-6", variant: "max" },
writing: { model: "kimi-for-coding/k2p5" },
writing: { model: "google/gemini-3-flash" },
}
export const CATEGORY_PROMPT_APPENDS: Record<string, string> = {

View File

@@ -3,12 +3,10 @@ const { describe, test, expect, beforeEach, afterEach, spyOn, mock } = require("
import { DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS, CATEGORY_DESCRIPTIONS, isPlanAgent, PLAN_AGENT_NAMES, isPlanFamily, PLAN_FAMILY_NAMES } from "./constants"
import { resolveCategoryConfig } from "./tools"
import type { CategoryConfig } from "../../config/schema"
import type { DelegateTaskArgs } from "./types"
import { __resetModelCache } from "../../shared/model-availability"
import { clearSkillCache } from "../../features/opencode-skill-loader/skill-content"
import { __setTimingConfig, __resetTimingConfig } from "./timing"
import * as connectedProvidersCache from "../../shared/connected-providers-cache"
import * as executor from "./executor"
const SYSTEM_DEFAULT_MODEL = "anthropic/claude-sonnet-4-5"
@@ -23,10 +21,6 @@ const TEST_AVAILABLE_MODELS = new Set([
"openai/gpt-5.3-codex",
])
type DelegateTaskArgsWithSerializedSkills = Omit<DelegateTaskArgs, "load_skills"> & {
load_skills: string
}
function createTestAvailableModels(): Set<string> {
return new Set(TEST_AVAILABLE_MODELS)
}
@@ -67,14 +61,13 @@ describe("sisyphus-task", () => {
})
describe("DEFAULT_CATEGORIES", () => {
test("visual-engineering category has model and variant config", () => {
test("visual-engineering category has model config", () => {
// given
const category = DEFAULT_CATEGORIES["visual-engineering"]
// when / #then
expect(category).toBeDefined()
expect(category.model).toBe("google/gemini-3-pro")
expect(category.variant).toBe("high")
})
test("ultrabrain category has model and variant config", () => {
@@ -263,134 +256,6 @@ describe("sisyphus-task", () => {
})
})
describe("load_skills parsing", () => {
test("parses valid JSON string into array before validation", async () => {
//#given
const { createDelegateTask } = require("./tools")
const mockManager = {
launch: async () => ({
id: "task-123",
status: "pending",
description: "Parse test",
agent: "sisyphus-junior",
sessionID: "test-session",
}),
}
const mockClient = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({}) },
provider: { list: async () => ({ data: { connected: ["openai"] } }) },
model: { list: async () => ({ data: [{ provider: "openai", id: "gpt-5.3-codex" }] }) },
session: {
create: async () => ({ data: { id: "test-session" } }),
prompt: async () => ({ data: {} }),
promptAsync: async () => ({ data: {} }),
messages: async () => ({ data: [] }),
status: async () => ({ data: {} }),
},
}
const tool = createDelegateTask({
manager: mockManager,
client: mockClient,
connectedProvidersOverride: TEST_CONNECTED_PROVIDERS,
availableModelsOverride: createTestAvailableModels(),
})
const toolContext = {
sessionID: "parent-session",
messageID: "parent-message",
agent: "sisyphus",
abort: new AbortController().signal,
}
const resolveSkillContentSpy = spyOn(executor, "resolveSkillContent").mockResolvedValue({
content: "resolved skill content",
error: null,
})
const args: DelegateTaskArgsWithSerializedSkills = {
description: "Parse valid string",
prompt: "Load skill parsing test",
category: "quick",
run_in_background: true,
load_skills: '["playwright", "git-master"]',
}
//#when
await tool.execute(args as unknown as DelegateTaskArgs, toolContext)
//#then
expect(args.load_skills).toEqual(["playwright", "git-master"])
expect(resolveSkillContentSpy).toHaveBeenCalledWith(["playwright", "git-master"], expect.any(Object))
}, { timeout: 10000 })
test("defaults to [] when load_skills is malformed JSON", async () => {
//#given
const { createDelegateTask } = require("./tools")
const mockManager = {
launch: async () => ({
id: "task-456",
status: "pending",
description: "Parse test",
agent: "sisyphus-junior",
sessionID: "test-session",
}),
}
const mockClient = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({}) },
provider: { list: async () => ({ data: { connected: ["openai"] } }) },
model: { list: async () => ({ data: [{ provider: "openai", id: "gpt-5.3-codex" }] }) },
session: {
create: async () => ({ data: { id: "test-session" } }),
prompt: async () => ({ data: {} }),
promptAsync: async () => ({ data: {} }),
messages: async () => ({ data: [] }),
status: async () => ({ data: {} }),
},
}
const tool = createDelegateTask({
manager: mockManager,
client: mockClient,
connectedProvidersOverride: TEST_CONNECTED_PROVIDERS,
availableModelsOverride: createTestAvailableModels(),
})
const toolContext = {
sessionID: "parent-session",
messageID: "parent-message",
agent: "sisyphus",
abort: new AbortController().signal,
}
const resolveSkillContentSpy = spyOn(executor, "resolveSkillContent").mockResolvedValue({
content: "resolved skill content",
error: null,
})
const args: DelegateTaskArgsWithSerializedSkills = {
description: "Parse malformed string",
prompt: "Load skill parsing test",
category: "quick",
run_in_background: true,
load_skills: '["playwright", "git-master"',
}
//#when
await tool.execute(args as unknown as DelegateTaskArgs, toolContext)
//#then
expect(args.load_skills).toEqual([])
expect(resolveSkillContentSpy).toHaveBeenCalledWith([], expect.any(Object))
}, { timeout: 10000 })
})
describe("category delegation config validation", () => {
test("fills subagent_type as sisyphus-junior when category is provided without subagent_type", async () => {
// given
@@ -1714,19 +1579,17 @@ describe("sisyphus-task", () => {
const { createDelegateTask } = require("./tools")
let launchCalled = false
const launchedTask = {
id: "task-unstable",
sessionID: "ses_unstable_gemini",
description: "Unstable gemini task",
agent: "sisyphus-junior",
status: "running",
}
const mockManager = {
launch: async () => {
launchCalled = true
return launchedTask
return {
id: "task-unstable",
sessionID: "ses_unstable_gemini",
description: "Unstable gemini task",
agent: "sisyphus-junior",
status: "running",
}
},
getTask: () => launchedTask,
}
const mockClient = {
@@ -1841,19 +1704,17 @@ describe("sisyphus-task", () => {
const { createDelegateTask } = require("./tools")
let launchCalled = false
const launchedTask = {
id: "task-unstable-minimax",
sessionID: "ses_unstable_minimax",
description: "Unstable minimax task",
agent: "sisyphus-junior",
status: "running",
}
const mockManager = {
launch: async () => {
launchCalled = true
return launchedTask
return {
id: "task-unstable-minimax",
sessionID: "ses_unstable_minimax",
description: "Unstable minimax task",
agent: "sisyphus-junior",
status: "running",
}
},
getTask: () => launchedTask,
}
const mockClient = {
@@ -1977,19 +1838,17 @@ describe("sisyphus-task", () => {
const { createDelegateTask } = require("./tools")
let launchCalled = false
const launchedTask = {
id: "task-artistry",
sessionID: "ses_artistry_gemini",
description: "Artistry gemini task",
agent: "sisyphus-junior",
status: "running",
}
const mockManager = {
launch: async () => {
launchCalled = true
return launchedTask
return {
id: "task-artistry",
sessionID: "ses_artistry_gemini",
description: "Artistry gemini task",
agent: "sisyphus-junior",
status: "running",
}
},
getTask: () => launchedTask,
}
const mockClient = {
@@ -2045,19 +1904,17 @@ describe("sisyphus-task", () => {
const { createDelegateTask } = require("./tools")
let launchCalled = false
const launchedTask = {
id: "task-writing",
sessionID: "ses_writing_gemini",
description: "Writing gemini task",
agent: "sisyphus-junior",
status: "running",
}
const mockManager = {
launch: async () => {
launchCalled = true
return launchedTask
return {
id: "task-writing",
sessionID: "ses_writing_gemini",
description: "Writing gemini task",
agent: "sisyphus-junior",
status: "running",
}
},
getTask: () => launchedTask,
}
const mockClient = {
@@ -2113,19 +1970,17 @@ describe("sisyphus-task", () => {
const { createDelegateTask } = require("./tools")
let launchCalled = false
const launchedTask = {
id: "task-custom-unstable",
sessionID: "ses_custom_unstable",
description: "Custom unstable task",
agent: "sisyphus-junior",
status: "running",
}
const mockManager = {
launch: async () => {
launchCalled = true
return launchedTask
return {
id: "task-custom-unstable",
sessionID: "ses_custom_unstable",
description: "Custom unstable task",
agent: "sisyphus-junior",
status: "running",
}
},
getTask: () => launchedTask,
}
const mockClient = {
@@ -2804,7 +2659,7 @@ describe("sisyphus-task", () => {
{
name: "writing",
description: "Documentation, prose, technical writing",
model: "kimi-for-coding/k2p5",
model: "google/gemini-3-flash",
},
]
const availableSkills = [

View File

@@ -103,14 +103,6 @@ Prompts MUST be in English.`
if (args.run_in_background === undefined) {
throw new Error(`Invalid arguments: 'run_in_background' parameter is REQUIRED. Use run_in_background=false for task delegation, run_in_background=true only for parallel exploration.`)
}
if (typeof args.load_skills === "string") {
try {
const parsed = JSON.parse(args.load_skills)
args.load_skills = Array.isArray(parsed) ? parsed : []
} catch {
args.load_skills = []
}
}
if (args.load_skills === undefined) {
throw new Error(`Invalid arguments: 'load_skills' parameter is REQUIRED. Pass [] if no skills needed, but IT IS HIGHLY RECOMMENDED to pass proper skills like ["playwright"], ["git-master"] for best results.`)
}

View File

@@ -1,224 +0,0 @@
const { describe, test, expect, beforeEach, afterEach, mock } = require("bun:test")
describe("executeUnstableAgentTask - interrupt detection", () => {
beforeEach(() => {
//#given - configure fast timing for all tests
const { __setTimingConfig } = require("./timing")
__setTimingConfig({
POLL_INTERVAL_MS: 10,
MIN_STABILITY_TIME_MS: 0,
STABILITY_POLLS_REQUIRED: 1,
MAX_POLL_TIME_MS: 500,
WAIT_FOR_SESSION_TIMEOUT_MS: 100,
WAIT_FOR_SESSION_INTERVAL_MS: 10,
})
})
afterEach(() => {
//#given - reset timing after each test
const { __resetTimingConfig } = require("./timing")
__resetTimingConfig()
mock.restore()
})
test("should return error immediately when background task becomes interrupted during polling", async () => {
//#given - a background task that gets interrupted on first poll check
const taskState = {
id: "bg_test_interrupt",
sessionID: "ses_test_interrupt",
status: "interrupt" as string,
description: "test interrupted task",
prompt: "test prompt",
agent: "sisyphus-junior",
error: "Agent not found" as string | undefined,
}
const launchState = { ...taskState, status: "running" as string, error: undefined as string | undefined }
const mockManager = {
launch: async () => launchState,
getTask: () => taskState,
}
const mockClient = {
session: {
status: async () => ({ data: { [taskState.sessionID!]: { type: "idle" } } }),
messages: async () => ({ data: [] }),
},
}
const { executeUnstableAgentTask } = require("./unstable-agent-task")
const args = {
prompt: "test prompt",
description: "test task",
category: "test",
load_skills: [],
run_in_background: false,
}
const mockCtx = {
sessionID: "parent-session",
callID: "call-123",
metadata: () => {},
}
const mockExecutorCtx = {
manager: mockManager,
client: mockClient,
directory: "/tmp",
}
const parentContext = {
sessionID: "parent-session",
messageID: "msg-123",
}
//#when - executeUnstableAgentTask encounters an interrupted task
const startTime = Date.now()
const result = await executeUnstableAgentTask(
args, mockCtx, mockExecutorCtx, parentContext,
"test-agent", undefined, undefined, "test-model"
)
const elapsed = Date.now() - startTime
//#then - should return quickly with interrupt error, not hang until MAX_POLL_TIME_MS
expect(result).toContain("interrupt")
expect(result.toLowerCase()).toContain("agent not found")
expect(elapsed).toBeLessThan(400)
})
test("should return error immediately when background task becomes errored during polling", async () => {
//#given - a background task that is already errored when poll checks
const taskState = {
id: "bg_test_error",
sessionID: "ses_test_error",
status: "error" as string,
description: "test error task",
prompt: "test prompt",
agent: "sisyphus-junior",
error: "Rate limit exceeded" as string | undefined,
}
const launchState = { ...taskState, status: "running" as string, error: undefined as string | undefined }
const mockManager = {
launch: async () => launchState,
getTask: () => taskState,
}
const mockClient = {
session: {
status: async () => ({ data: { [taskState.sessionID!]: { type: "idle" } } }),
messages: async () => ({ data: [] }),
},
}
const { executeUnstableAgentTask } = require("./unstable-agent-task")
const args = {
prompt: "test prompt",
description: "test task",
category: "test",
load_skills: [],
run_in_background: false,
}
const mockCtx = {
sessionID: "parent-session",
callID: "call-123",
metadata: () => {},
}
const mockExecutorCtx = {
manager: mockManager,
client: mockClient,
directory: "/tmp",
}
const parentContext = {
sessionID: "parent-session",
messageID: "msg-123",
}
//#when - executeUnstableAgentTask encounters an errored task
const startTime = Date.now()
const result = await executeUnstableAgentTask(
args, mockCtx, mockExecutorCtx, parentContext,
"test-agent", undefined, undefined, "test-model"
)
const elapsed = Date.now() - startTime
//#then - should return quickly with error, not hang until MAX_POLL_TIME_MS
expect(result).toContain("error")
expect(result.toLowerCase()).toContain("rate limit exceeded")
expect(elapsed).toBeLessThan(400)
})
test("should return error immediately when background task becomes cancelled during polling", async () => {
//#given - a background task that is already cancelled when poll checks
const taskState = {
id: "bg_test_cancel",
sessionID: "ses_test_cancel",
status: "cancelled" as string,
description: "test cancelled task",
prompt: "test prompt",
agent: "sisyphus-junior",
error: "Stale timeout" as string | undefined,
}
const launchState = { ...taskState, status: "running" as string, error: undefined as string | undefined }
const mockManager = {
launch: async () => launchState,
getTask: () => taskState,
}
const mockClient = {
session: {
status: async () => ({ data: { [taskState.sessionID!]: { type: "idle" } } }),
messages: async () => ({ data: [] }),
},
}
const { executeUnstableAgentTask } = require("./unstable-agent-task")
const args = {
prompt: "test prompt",
description: "test task",
category: "test",
load_skills: [],
run_in_background: false,
}
const mockCtx = {
sessionID: "parent-session",
callID: "call-123",
metadata: () => {},
}
const mockExecutorCtx = {
manager: mockManager,
client: mockClient,
directory: "/tmp",
}
const parentContext = {
sessionID: "parent-session",
messageID: "msg-123",
}
//#when - executeUnstableAgentTask encounters a cancelled task
const startTime = Date.now()
const result = await executeUnstableAgentTask(
args, mockCtx, mockExecutorCtx, parentContext,
"test-agent", undefined, undefined, "test-model"
)
const elapsed = Date.now() - startTime
//#then - should return quickly with cancel info, not hang until MAX_POLL_TIME_MS
expect(result).toContain("cancel")
expect(result.toLowerCase()).toContain("stale timeout")
expect(elapsed).toBeLessThan(400)
})
})

View File

@@ -77,7 +77,6 @@ export async function executeUnstableAgentTask(
const pollStart = Date.now()
let lastMsgCount = 0
let stablePolls = 0
let terminalStatus: { status: string; error?: string } | undefined
while (Date.now() - pollStart < timingCfg.MAX_POLL_TIME_MS) {
if (ctx.abort?.aborted) {
@@ -86,12 +85,6 @@ export async function executeUnstableAgentTask(
await new Promise(resolve => setTimeout(resolve, timingCfg.POLL_INTERVAL_MS))
const currentTask = manager.getTask(task.id)
if (currentTask && (currentTask.status === "interrupt" || currentTask.status === "error" || currentTask.status === "cancelled")) {
terminalStatus = { status: currentTask.status, error: currentTask.error }
break
}
const statusResult = await client.session.status()
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>
const sessionStatus = allStatuses[sessionID]
@@ -117,24 +110,6 @@ export async function executeUnstableAgentTask(
}
}
if (terminalStatus) {
const duration = formatDuration(startTime)
return `SUPERVISED TASK FAILED (${terminalStatus.status})
Task was interrupted/failed while running in monitored background mode.
${terminalStatus.error ? `Error: ${terminalStatus.error}` : ""}
Duration: ${duration}
Agent: ${agentToUse}${args.category ? ` (category: ${args.category})` : ""}
Model: ${actualModel}
The task session may contain partial results.
<task_metadata>
session_id: ${sessionID}
</task_metadata>`
}
const messagesResult = await client.session.messages({ path: { id: sessionID } })
const messages = ((messagesResult as { data?: unknown }).data ?? messagesResult) as SessionMessage[]

View File

@@ -2,7 +2,9 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { describe, it, expect, spyOn, mock, beforeEach, afterEach } from "bun:test"
import { describe, it, expect, spyOn, mock, beforeEach, afterEach, afterAll } from "bun:test"
const realJsonRpcNode = await import("vscode-jsonrpc/node")
mock.module("vscode-jsonrpc/node", () => ({
createMessageConnection: () => {
@@ -12,6 +14,10 @@ mock.module("vscode-jsonrpc/node", () => ({
StreamMessageWriter: function StreamMessageWriter() {},
}))
afterAll(() => {
mock.module("vscode-jsonrpc/node", () => ({ ...realJsonRpcNode }))
})
import { LSPClient, lspManager, validateCwd } from "./client"
import type { ResolvedServer } from "./types"

View File

@@ -1,37 +0,0 @@
import { mkdtempSync, rmSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { describe, expect, it, spyOn } from "bun:test"
describe("spawnProcess", () => {
it("proceeds to node spawn on Windows when command is available", async () => {
//#given
const originalPlatform = process.platform
const rootDir = mkdtempSync(join(tmpdir(), "lsp-process-test-"))
const childProcess = await import("node:child_process")
const nodeSpawnSpy = spyOn(childProcess, "spawn")
try {
Object.defineProperty(process, "platform", { value: "win32" })
const { spawnProcess } = await import("./lsp-process")
//#when
let result: ReturnType<typeof spawnProcess> | null = null
expect(() => {
result = spawnProcess(["node", "--version"], {
cwd: rootDir,
env: process.env,
})
}).not.toThrow(/Binary 'node' not found/)
//#then
expect(nodeSpawnSpy).toHaveBeenCalled()
expect(result).not.toBeNull()
} finally {
Object.defineProperty(process, "platform", { value: originalPlatform })
nodeSpawnSpy.mockRestore()
rmSync(rootDir, { recursive: true, force: true })
}
})
})

View File

@@ -1,5 +1,5 @@
import { spawn as bunSpawn } from "bun"
import { spawn as nodeSpawn, type ChildProcess } from "node:child_process"
import { spawn as nodeSpawn, spawnSync, type ChildProcess } from "node:child_process"
import { existsSync, statSync } from "fs"
import { log } from "../../shared/logger"
// Bun spawn segfaults on Windows (oven-sh/bun#25798) — unfixed as of v1.3.8+
@@ -21,6 +21,24 @@ export function validateCwd(cwd: string): { valid: boolean; error?: string } {
return { valid: false, error: `Cannot access working directory: ${cwd} (${err instanceof Error ? err.message : String(err)})` }
}
}
function isBinaryAvailableOnWindows(command: string): boolean {
if (process.platform !== "win32") return true
if (command.includes("/") || command.includes("\\")) {
return existsSync(command)
}
try {
const result = spawnSync("where", [command], {
shell: true,
windowsHide: true,
timeout: 5000,
})
return result.status === 0
} catch {
return true
}
}
interface StreamReader {
read(): Promise<{ done: boolean; value: Uint8Array | undefined }>
}
@@ -140,6 +158,13 @@ export function spawnProcess(
}
if (shouldUseNodeSpawn()) {
const [cmd, ...args] = command
if (!isBinaryAvailableOnWindows(cmd)) {
throw new Error(
`[LSP] Binary '${cmd}' not found on Windows. ` +
`Ensure the LSP server is installed and available in PATH. ` +
`For npm packages, try: npm install -g ${cmd}`
)
}
log("[LSP] Using Node.js child_process on Windows to avoid Bun spawn segfault")
const proc = nodeSpawn(cmd, args, {
cwd: options.cwd,

View File

@@ -1,9 +1,11 @@
import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test"
import { describe, test, expect, beforeEach, afterEach, afterAll, mock } from "bun:test"
import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { randomUUID } from "node:crypto"
const realConstants = await import("./constants")
const TEST_DIR = join(tmpdir(), `omo-test-session-manager-${randomUUID()}`)
const TEST_MESSAGE_STORAGE = join(TEST_DIR, "message")
const TEST_PART_STORAGE = join(TEST_DIR, "part")
@@ -26,6 +28,10 @@ mock.module("./constants", () => ({
TOOL_NAME_PREFIX: "session_",
}))
afterAll(() => {
mock.module("./constants", () => ({ ...realConstants }))
})
const { getAllSessions, getMessageDir, sessionExists, readSessionMessages, readSessionTodos, getSessionInfo } =
await import("./storage")

View File

@@ -1,4 +1,4 @@
import { afterAll, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"
import { describe, it, expect, beforeEach, mock, spyOn, afterAll } from "bun:test"
import type { ToolContext } from "@opencode-ai/plugin/tool"
import * as fs from "node:fs"
import { createSkillTool } from "./tools"
@@ -8,6 +8,8 @@ import type { Tool as McpTool } from "@modelcontextprotocol/sdk/types.js"
const originalReadFileSync = fs.readFileSync.bind(fs)
const realNodeFs = await import("node:fs")
mock.module("node:fs", () => ({
...fs,
readFileSync: (path: string, encoding?: string) => {
@@ -22,7 +24,7 @@ Test skill body content`
}))
afterAll(() => {
mock.restore()
mock.module("node:fs", () => ({ ...realNodeFs }))
})
function createMockSkill(name: string, options: { agent?: string } = {}): LoadedSkill {