Compare commits
4 Commits
v3.5.6
...
fix/inheri
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
271929a9e4 | ||
|
|
945329e261 | ||
|
|
f27733eae2 | ||
|
|
e9c9cb696d |
32
.github/workflows/publish.yml
vendored
32
.github/workflows/publish.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
28
bun.lock
28
bun.lock
@@ -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=="],
|
||||
|
||||
|
||||
16
package.json
16
package.json
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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\`
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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: {
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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" })
|
||||
}
|
||||
|
||||
@@ -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 }))
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -154,7 +154,8 @@ export const THINKING_CONFIGS = {
|
||||
"zai-coding-plan": {
|
||||
extra_body: {
|
||||
thinking: {
|
||||
type: "disabled",
|
||||
type: "enabled",
|
||||
clear_thinking: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -29,7 +29,6 @@ export interface SessionState {
|
||||
abortDetectedAt?: number
|
||||
lastInjectedAt?: number
|
||||
inFlight?: boolean
|
||||
consecutiveFailures: number
|
||||
}
|
||||
|
||||
export interface MessageInfo {
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
|
||||
14
src/shared/agents-config-dir.test.ts
Normal file
14
src/shared/agents-config-dir.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
6
src/shared/agents-config-dir.ts
Normal file
6
src/shared/agents-config-dir.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
|
||||
export function getAgentsConfigDir(): string {
|
||||
return join(homedir(), ".agents")
|
||||
}
|
||||
@@ -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:
|
||||
//
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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.`)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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[]
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user