Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50afb6b2de | ||
|
|
41d790dc04 | ||
|
|
2ac2241367 | ||
|
|
283c7e6cb7 | ||
|
|
95aa7595f8 | ||
|
|
c6349dc38a | ||
|
|
17b475eefd | ||
|
|
3a019792e9 | ||
|
|
1ceaaa4311 | ||
|
|
ff8a5f343a | ||
|
|
118150035c | ||
|
|
157952f293 | ||
|
|
d358e6e48e | ||
|
|
9afd0d1d41 | ||
|
|
e4be8cea75 | ||
|
|
306c7f4c8e | ||
|
|
c12c6fa0c0 | ||
|
|
ef1baea163 | ||
|
|
d33af1d27f | ||
|
|
b2f019a987 | ||
|
|
ce7fb00847 | ||
|
|
63d3fa7439 | ||
|
|
2df61a2199 | ||
|
|
96f0e787e7 | ||
|
|
4ef6188a41 | ||
|
|
d5fd918bff | ||
|
|
5d3215167a | ||
|
|
3b2d3acd17 | ||
|
|
bfe1730e9f | ||
|
|
67b4665c28 | ||
|
|
b0c570e054 | ||
|
|
fd99a29d6e | ||
|
|
308ad1e98e | ||
|
|
d60697bb13 | ||
|
|
95a4e971a0 | ||
|
|
d8901fa658 | ||
|
|
82c71425a0 | ||
|
|
7e0ab828f9 | ||
|
|
13d960f3ca | ||
|
|
687cc2386f | ||
|
|
d88449b1e2 |
2
.github/workflows/cla.yml
vendored
2
.github/workflows/cla.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
path-to-signatures: 'signatures/cla.json'
|
||||
path-to-document: 'https://github.com/code-yeongyu/oh-my-opencode/blob/master/CLA.md'
|
||||
branch: 'dev'
|
||||
allowlist: code-yeongyu,bot*,dependabot*,github-actions*,*[bot],sisyphus-dev-ai
|
||||
allowlist: code-yeongyu,bot*,dependabot*,github-actions*,*[bot],sisyphus-dev-ai,web-flow
|
||||
custom-notsigned-prcomment: |
|
||||
Thank you for your contribution! Before we can merge this PR, we need you to sign our [Contributor License Agreement (CLA)](https://github.com/code-yeongyu/oh-my-opencode/blob/master/CLA.md).
|
||||
|
||||
|
||||
28
bun.lock
28
bun.lock
@@ -28,13 +28,13 @@
|
||||
"typescript": "^5.7.3",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"oh-my-opencode-darwin-arm64": "3.3.1",
|
||||
"oh-my-opencode-darwin-x64": "3.3.1",
|
||||
"oh-my-opencode-linux-arm64": "3.3.1",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.3.1",
|
||||
"oh-my-opencode-linux-x64": "3.3.1",
|
||||
"oh-my-opencode-linux-x64-musl": "3.3.1",
|
||||
"oh-my-opencode-windows-x64": "3.3.1",
|
||||
"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.3.1", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-R+o42Km6bsIaW6D3I8uu2HCF3BjIWqa/fg38W5y4hJEOw4mL0Q7uV4R+0vtrXRHo9crXTK9ag0fqVQUm+Y6iAQ=="],
|
||||
"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.3.1", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-7VTbpR1vH3OEkoJxBKtYuxFPX8M3IbJKoeHWME9iK6FpT11W1ASsjyuhvzB1jcxSeqF8ddMnjitlG5ub6h5EVw=="],
|
||||
"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.3.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-BZ/r/CFlvbOxkdZZrRoT16xFOjibRZHuwQnaE4f0JvOzgK6/HWp3zJI1+2/aX/oK5GA6lZxNWRrJC/SKUi8LEg=="],
|
||||
"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.3.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-U90Wruf21h+CJbtcrS7MeTAc/5VOF6RI+5jr7qj/cCxjXNJtjhyJdz/maehArjtgf304+lYCM/Mh1i+G2D3YFQ=="],
|
||||
"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.3.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-sYzohSNdwsAhivbXcbhPdF1qqQi2CCI7FSgbmvvfBOMyZ8HAgqOFqYW2r3GPdmtywzkjOTvCzTG56FZwEjx15w=="],
|
||||
"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.3.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-aG5pZ4eWS0YSGUicOnjMkUPrIqQV4poYF+d9SIvrfvlaMcK6WlQn7jXzgNCwJsfGn5lyhSmjshZBEU+v79Ua3w=="],
|
||||
"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.3.1", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-FGH7cnzBqNwjSkzCDglMsVttaq+MsykAxa7ehaFK+0dnBZArvllS3W13a3dGaANHMZzfK0vz8hNDUdVi7Z63cA=="],
|
||||
"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.0",
|
||||
"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.0",
|
||||
"oh-my-opencode-darwin-x64": "3.5.0",
|
||||
"oh-my-opencode-linux-arm64": "3.5.0",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.5.0",
|
||||
"oh-my-opencode-linux-x64": "3.5.0",
|
||||
"oh-my-opencode-linux-x64-musl": "3.5.0",
|
||||
"oh-my-opencode-windows-x64": "3.5.0"
|
||||
"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.0",
|
||||
"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.0",
|
||||
"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.0",
|
||||
"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.0",
|
||||
"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.0",
|
||||
"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.0",
|
||||
"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.0",
|
||||
"version": "3.5.3",
|
||||
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1319,6 +1319,126 @@
|
||||
"created_at": "2026-02-10T15:32:31Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1723
|
||||
},
|
||||
{
|
||||
"name": "sjawhar",
|
||||
"id": 5074378,
|
||||
"comment_id": 3879746658,
|
||||
"created_at": "2026-02-10T17:43:47Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1727
|
||||
},
|
||||
{
|
||||
"name": "marlon-costa-dc",
|
||||
"id": 128386606,
|
||||
"comment_id": 3879827362,
|
||||
"created_at": "2026-02-10T17:59:06Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1726
|
||||
},
|
||||
{
|
||||
"name": "marlon-costa-dc",
|
||||
"id": 128386606,
|
||||
"comment_id": 3879847814,
|
||||
"created_at": "2026-02-10T18:03:41Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1726
|
||||
},
|
||||
{
|
||||
"name": "danpung2",
|
||||
"id": 75434746,
|
||||
"comment_id": 3881834946,
|
||||
"created_at": "2026-02-11T02:52:34Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1741
|
||||
},
|
||||
{
|
||||
"name": "ojh102",
|
||||
"id": 14901903,
|
||||
"comment_id": 3882254163,
|
||||
"created_at": "2026-02-11T05:29:51Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1750
|
||||
},
|
||||
{
|
||||
"name": "uyu423",
|
||||
"id": 8033320,
|
||||
"comment_id": 3884127858,
|
||||
"created_at": "2026-02-11T12:30:37Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1762
|
||||
},
|
||||
{
|
||||
"name": "WietRob",
|
||||
"id": 203506602,
|
||||
"comment_id": 3859280254,
|
||||
"created_at": "2026-02-06T10:00:03Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1529
|
||||
},
|
||||
{
|
||||
"name": "COLDTURNIP",
|
||||
"id": 46220,
|
||||
"comment_id": 3884966424,
|
||||
"created_at": "2026-02-11T14:54:46Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1765
|
||||
},
|
||||
{
|
||||
"name": "tcarac",
|
||||
"id": 64477810,
|
||||
"comment_id": 3885026481,
|
||||
"created_at": "2026-02-11T15:03:25Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1766
|
||||
},
|
||||
{
|
||||
"name": "youngbinkim0",
|
||||
"id": 64558592,
|
||||
"comment_id": 3887466814,
|
||||
"created_at": "2026-02-11T22:03:00Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1777
|
||||
},
|
||||
{
|
||||
"name": "raki-1203",
|
||||
"id": 52475378,
|
||||
"comment_id": 3889111683,
|
||||
"created_at": "2026-02-12T07:27:39Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1790
|
||||
},
|
||||
{
|
||||
"name": "G36maid",
|
||||
"id": 53391375,
|
||||
"comment_id": 3889208379,
|
||||
"created_at": "2026-02-12T07:56:21Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1791
|
||||
},
|
||||
{
|
||||
"name": "solssak",
|
||||
"id": 107416133,
|
||||
"comment_id": 3889740003,
|
||||
"created_at": "2026-02-12T09:28:09Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1794
|
||||
},
|
||||
{
|
||||
"name": "bvanderhorn",
|
||||
"id": 9591412,
|
||||
"comment_id": 3890297580,
|
||||
"created_at": "2026-02-12T11:17:38Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1799
|
||||
},
|
||||
{
|
||||
"name": "jardo5",
|
||||
"id": 22041729,
|
||||
"comment_id": 3890810423,
|
||||
"created_at": "2026-02-12T12:57:06Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1802
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentFactory } from "./types"
|
||||
import type { CategoriesConfig, CategoryConfig, GitMasterConfig } from "../config/schema"
|
||||
import type { BrowserAutomationProvider } from "../config/schema"
|
||||
import { DEFAULT_CATEGORIES } from "../tools/delegate-task/constants"
|
||||
import { mergeCategories } from "../shared/merge-categories"
|
||||
import { resolveMultipleSkills } from "../features/opencode-skill-loader/skill-content"
|
||||
|
||||
export type AgentSource = AgentFactory | AgentConfig
|
||||
@@ -20,9 +20,7 @@ export function buildAgent(
|
||||
disabledSkills?: Set<string>
|
||||
): AgentConfig {
|
||||
const base = isFactory(source) ? source(model) : { ...source }
|
||||
const categoryConfigs: Record<string, CategoryConfig> = categories
|
||||
? { ...DEFAULT_CATEGORIES, ...categories }
|
||||
: DEFAULT_CATEGORIES
|
||||
const categoryConfigs: Record<string, CategoryConfig> = mergeCategories(categories)
|
||||
|
||||
const agentWithCategory = base as AgentConfig & { category?: string; skills?: string[]; variant?: string }
|
||||
if (agentWithCategory.category) {
|
||||
|
||||
@@ -15,7 +15,7 @@ import { isGptModel } from "../types"
|
||||
import type { AvailableAgent, AvailableSkill, AvailableCategory } from "../dynamic-agent-prompt-builder"
|
||||
import { buildCategorySkillsDelegationGuide } from "../dynamic-agent-prompt-builder"
|
||||
import type { CategoryConfig } from "../../config/schema"
|
||||
import { DEFAULT_CATEGORIES } from "../../tools/delegate-task/constants"
|
||||
import { mergeCategories } from "../../shared/merge-categories"
|
||||
import { createAgentToolRestrictions } from "../../shared/permission-compat"
|
||||
|
||||
import { getDefaultAtlasPrompt } from "./default"
|
||||
@@ -70,7 +70,7 @@ function buildDynamicOrchestratorPrompt(ctx?: OrchestratorContext): string {
|
||||
const userCategories = ctx?.userCategories
|
||||
const model = ctx?.model
|
||||
|
||||
const allCategories = { ...DEFAULT_CATEGORIES, ...userCategories }
|
||||
const allCategories = mergeCategories(userCategories)
|
||||
const availableCategories: AvailableCategory[] = Object.entries(allCategories).map(([name]) => ({
|
||||
name,
|
||||
description: getCategoryDescription(name, userCategories),
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
|
||||
import type { CategoryConfig } from "../../config/schema"
|
||||
import { formatCustomSkillsBlock, type AvailableAgent, type AvailableSkill } from "../dynamic-agent-prompt-builder"
|
||||
import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "../../tools/delegate-task/constants"
|
||||
import { CATEGORY_DESCRIPTIONS } from "../../tools/delegate-task/constants"
|
||||
import { mergeCategories } from "../../shared/merge-categories"
|
||||
import { truncateDescription } from "../../shared/truncate-description"
|
||||
|
||||
export const getCategoryDescription = (name: string, userCategories?: Record<string, CategoryConfig>) =>
|
||||
@@ -33,7 +34,7 @@ ${rows.join("\n")}`
|
||||
}
|
||||
|
||||
export function buildCategorySection(userCategories?: Record<string, CategoryConfig>): string {
|
||||
const allCategories = { ...DEFAULT_CATEGORIES, ...userCategories }
|
||||
const allCategories = mergeCategories(userCategories)
|
||||
const categoryRows = Object.entries(allCategories).map(([name, config]) => {
|
||||
const temp = config.temperature ?? 0.5
|
||||
return `| \`${name}\` | ${temp} | ${getCategoryDescription(name, userCategories)} |`
|
||||
@@ -116,7 +117,7 @@ task(category="[category]", load_skills=["skill-1", "skill-2"], run_in_backgroun
|
||||
}
|
||||
|
||||
export function buildDecisionMatrix(agents: AvailableAgent[], userCategories?: Record<string, CategoryConfig>): string {
|
||||
const allCategories = { ...DEFAULT_CATEGORIES, ...userCategories }
|
||||
const allCategories = mergeCategories(userCategories)
|
||||
|
||||
const categoryRows = Object.entries(allCategories).map(([name]) =>
|
||||
`| ${getCategoryDescription(name, userCategories)} | \`category="${name}", load_skills=[...]\` |`
|
||||
|
||||
@@ -14,7 +14,8 @@ import { createMomusAgent, momusPromptMetadata } from "./momus"
|
||||
import { createHephaestusAgent } from "./hephaestus"
|
||||
import type { AvailableCategory } from "./dynamic-agent-prompt-builder"
|
||||
import { fetchAvailableModels, readConnectedProvidersCache } from "../shared"
|
||||
import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "../tools/delegate-task/constants"
|
||||
import { CATEGORY_DESCRIPTIONS } from "../tools/delegate-task/constants"
|
||||
import { mergeCategories } from "../shared/merge-categories"
|
||||
import { buildAvailableSkills } from "./builtin-agents/available-skills"
|
||||
import { collectPendingBuiltinAgents } from "./builtin-agents/general-agents"
|
||||
import { maybeCreateSisyphusConfig } from "./builtin-agents/sisyphus-agent"
|
||||
@@ -78,9 +79,7 @@ export async function createBuiltinAgents(
|
||||
|
||||
const result: Record<string, AgentConfig> = {}
|
||||
|
||||
const mergedCategories = categories
|
||||
? { ...DEFAULT_CATEGORIES, ...categories }
|
||||
: DEFAULT_CATEGORIES
|
||||
const mergedCategories = mergeCategories(categories)
|
||||
|
||||
const availableCategories: AvailableCategory[] = Object.entries(mergedCategories).map(([name]) => ({
|
||||
name,
|
||||
|
||||
@@ -45,6 +45,7 @@ describe("pollForCompletion", () => {
|
||||
const result = await pollForCompletion(ctx, eventState, abortController, {
|
||||
pollIntervalMs: 10,
|
||||
requiredConsecutive: 3,
|
||||
minStabilizationMs: 0,
|
||||
})
|
||||
|
||||
//#then - exits with 0 but only after 3 consecutive checks
|
||||
@@ -53,6 +54,30 @@ describe("pollForCompletion", () => {
|
||||
expect(todoCallCount).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it("does not check completion during stabilization period after first meaningful work", async () => {
|
||||
//#given - session idle, meaningful work done, but stabilization period not elapsed
|
||||
spyOn(console, "log").mockImplementation(() => {})
|
||||
spyOn(console, "error").mockImplementation(() => {})
|
||||
const ctx = createMockContext()
|
||||
const eventState = createEventState()
|
||||
eventState.mainSessionIdle = true
|
||||
eventState.hasReceivedMeaningfulWork = true
|
||||
const abortController = new AbortController()
|
||||
|
||||
//#when - abort after 50ms (within the 60ms stabilization period)
|
||||
setTimeout(() => abortController.abort(), 50)
|
||||
const result = await pollForCompletion(ctx, eventState, abortController, {
|
||||
pollIntervalMs: 10,
|
||||
requiredConsecutive: 3,
|
||||
minStabilizationMs: 60,
|
||||
})
|
||||
|
||||
//#then - should be aborted, not completed (stabilization blocked completion check)
|
||||
expect(result).toBe(130)
|
||||
const todoCallCount = (ctx.client.session.todo as ReturnType<typeof mock>).mock.calls.length
|
||||
expect(todoCallCount).toBe(0)
|
||||
})
|
||||
|
||||
it("does not exit when currentTool is set - resets consecutive counter", async () => {
|
||||
//#given
|
||||
spyOn(console, "log").mockImplementation(() => {})
|
||||
@@ -110,6 +135,7 @@ describe("pollForCompletion", () => {
|
||||
const result = await pollForCompletion(ctx, eventState, abortController, {
|
||||
pollIntervalMs: 10,
|
||||
requiredConsecutive: 3,
|
||||
minStabilizationMs: 0,
|
||||
})
|
||||
const elapsedMs = Date.now() - startMs
|
||||
|
||||
|
||||
@@ -6,10 +6,12 @@ import { checkCompletionConditions } from "./completion"
|
||||
const DEFAULT_POLL_INTERVAL_MS = 500
|
||||
const DEFAULT_REQUIRED_CONSECUTIVE = 3
|
||||
const ERROR_GRACE_CYCLES = 3
|
||||
const MIN_STABILIZATION_MS = 10_000
|
||||
|
||||
export interface PollOptions {
|
||||
pollIntervalMs?: number
|
||||
requiredConsecutive?: number
|
||||
minStabilizationMs?: number
|
||||
}
|
||||
|
||||
export async function pollForCompletion(
|
||||
@@ -21,8 +23,11 @@ export async function pollForCompletion(
|
||||
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS
|
||||
const requiredConsecutive =
|
||||
options.requiredConsecutive ?? DEFAULT_REQUIRED_CONSECUTIVE
|
||||
const minStabilizationMs =
|
||||
options.minStabilizationMs ?? MIN_STABILIZATION_MS
|
||||
let consecutiveCompleteChecks = 0
|
||||
let errorCycleCount = 0
|
||||
let firstWorkTimestamp: number | null = null
|
||||
|
||||
while (!abortController.signal.aborted) {
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
|
||||
@@ -61,6 +66,17 @@ export async function pollForCompletion(
|
||||
continue
|
||||
}
|
||||
|
||||
// Track when first meaningful work was received
|
||||
if (firstWorkTimestamp === null) {
|
||||
firstWorkTimestamp = Date.now()
|
||||
}
|
||||
|
||||
// Don't check completion during stabilization period
|
||||
if (Date.now() - firstWorkTimestamp < minStabilizationMs) {
|
||||
consecutiveCompleteChecks = 0
|
||||
continue
|
||||
}
|
||||
|
||||
const shouldExit = await checkCompletionConditions(ctx)
|
||||
if (shouldExit) {
|
||||
consecutiveCompleteChecks++
|
||||
|
||||
@@ -65,7 +65,7 @@ export async function run(options: RunOptions): Promise<number> {
|
||||
console.log(pc.dim(`Session: ${sessionID}`))
|
||||
|
||||
const ctx: RunContext = { client, sessionID, directory, abortController }
|
||||
const events = await client.event.subscribe()
|
||||
const events = await client.event.subscribe({ query: { directory } })
|
||||
const eventState = createEventState()
|
||||
const eventProcessor = processEvents(ctx, events.stream, eventState).catch(
|
||||
() => {},
|
||||
|
||||
@@ -20,6 +20,8 @@ export const CategoryConfigSchema = z.object({
|
||||
prompt_append: z.string().optional(),
|
||||
/** Mark agent as unstable - forces background mode for monitoring. Auto-enabled for gemini/minimax models. */
|
||||
is_unstable_agent: z.boolean().optional(),
|
||||
/** Disable this category. Disabled categories are excluded from task delegation. */
|
||||
disable: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const BuiltinCategoryNameSchema = z.enum([
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { log } from "../../shared"
|
||||
import { MIN_IDLE_TIME_MS } from "./constants"
|
||||
import { subagentSessions } from "../claude-code-session-state"
|
||||
import type { BackgroundTask } from "./types"
|
||||
import { cleanupTaskAfterSessionEnds } from "./session-task-cleanup"
|
||||
import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler"
|
||||
|
||||
type Event = { type: string; properties?: Record<string, unknown> }
|
||||
|
||||
@@ -18,6 +18,7 @@ export function handleBackgroundEvent(args: {
|
||||
event: Event
|
||||
findBySession: (sessionID: string) => BackgroundTask | undefined
|
||||
getAllDescendantTasks: (sessionID: string) => BackgroundTask[]
|
||||
releaseConcurrencyKey?: (key: string) => void
|
||||
cancelTask: (
|
||||
taskId: string,
|
||||
options: { source: string; reason: string; skipNotification: true }
|
||||
@@ -36,6 +37,7 @@ export function handleBackgroundEvent(args: {
|
||||
event,
|
||||
findBySession,
|
||||
getAllDescendantTasks,
|
||||
releaseConcurrencyKey,
|
||||
cancelTask,
|
||||
tryCompleteTask,
|
||||
validateSessionHasOutput,
|
||||
@@ -78,6 +80,19 @@ export function handleBackgroundEvent(args: {
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
if (!props || !isRecord(props)) return
|
||||
handleSessionIdleBackgroundEvent({
|
||||
properties: props,
|
||||
findBySession,
|
||||
idleDeferralTimers,
|
||||
validateSessionHasOutput,
|
||||
checkSessionTodos,
|
||||
tryCompleteTask,
|
||||
emitIdleEvent,
|
||||
})
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
if (!props || !isRecord(props)) return
|
||||
const sessionID = getString(props, "sessionID")
|
||||
if (!sessionID) return
|
||||
@@ -85,64 +100,26 @@ export function handleBackgroundEvent(args: {
|
||||
const task = findBySession(sessionID)
|
||||
if (!task || task.status !== "running") return
|
||||
|
||||
const startedAt = task.startedAt
|
||||
if (!startedAt) return
|
||||
const errorRaw = props["error"]
|
||||
const dataRaw = isRecord(errorRaw) ? errorRaw["data"] : undefined
|
||||
const message =
|
||||
(isRecord(dataRaw) ? getString(dataRaw, "message") : undefined) ??
|
||||
(isRecord(errorRaw) ? getString(errorRaw, "message") : undefined) ??
|
||||
"Session error"
|
||||
|
||||
const elapsedMs = Date.now() - startedAt.getTime()
|
||||
if (elapsedMs < MIN_IDLE_TIME_MS) {
|
||||
const remainingMs = MIN_IDLE_TIME_MS - elapsedMs
|
||||
if (!idleDeferralTimers.has(task.id)) {
|
||||
log("[background-agent] Deferring early session.idle:", {
|
||||
elapsedMs,
|
||||
remainingMs,
|
||||
taskId: task.id,
|
||||
})
|
||||
const timer = setTimeout(() => {
|
||||
idleDeferralTimers.delete(task.id)
|
||||
emitIdleEvent(sessionID)
|
||||
}, remainingMs)
|
||||
idleDeferralTimers.set(task.id, timer)
|
||||
} else {
|
||||
log("[background-agent] session.idle already deferred:", { elapsedMs, taskId: task.id })
|
||||
}
|
||||
return
|
||||
}
|
||||
task.status = "error"
|
||||
task.error = message
|
||||
task.completedAt = new Date()
|
||||
|
||||
validateSessionHasOutput(sessionID)
|
||||
.then(async (hasValidOutput) => {
|
||||
if (task.status !== "running") {
|
||||
log("[background-agent] Task status changed during validation, skipping:", {
|
||||
taskId: task.id,
|
||||
status: task.status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasValidOutput) {
|
||||
log("[background-agent] Session.idle but no valid output yet, waiting:", task.id)
|
||||
return
|
||||
}
|
||||
|
||||
const hasIncompleteTodos = await checkSessionTodos(sessionID)
|
||||
|
||||
if (task.status !== "running") {
|
||||
log("[background-agent] Task status changed during todo check, skipping:", {
|
||||
taskId: task.id,
|
||||
status: task.status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (hasIncompleteTodos) {
|
||||
log("[background-agent] Task has incomplete todos, waiting for todo-continuation:", task.id)
|
||||
return
|
||||
}
|
||||
|
||||
await tryCompleteTask(task, "session.idle event")
|
||||
})
|
||||
.catch((err) => {
|
||||
log("[background-agent] Error in session.idle handler:", err)
|
||||
})
|
||||
cleanupTaskAfterSessionEnds({
|
||||
task,
|
||||
tasks,
|
||||
idleDeferralTimers,
|
||||
completionTimers,
|
||||
cleanupPendingByParent,
|
||||
clearNotificationsForTask,
|
||||
releaseConcurrencyKey,
|
||||
})
|
||||
}
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
@@ -176,24 +153,15 @@ export function handleBackgroundEvent(args: {
|
||||
})
|
||||
}
|
||||
|
||||
const completionTimer = completionTimers.get(task.id)
|
||||
if (completionTimer) {
|
||||
clearTimeout(completionTimer)
|
||||
completionTimers.delete(task.id)
|
||||
}
|
||||
|
||||
const idleTimer = idleDeferralTimers.get(task.id)
|
||||
if (idleTimer) {
|
||||
clearTimeout(idleTimer)
|
||||
idleDeferralTimers.delete(task.id)
|
||||
}
|
||||
|
||||
cleanupPendingByParent(task)
|
||||
tasks.delete(task.id)
|
||||
clearNotificationsForTask(task.id)
|
||||
if (task.sessionID) {
|
||||
subagentSessions.delete(task.sessionID)
|
||||
}
|
||||
cleanupTaskAfterSessionEnds({
|
||||
task,
|
||||
tasks,
|
||||
idleDeferralTimers,
|
||||
completionTimers,
|
||||
cleanupPendingByParent,
|
||||
clearNotificationsForTask,
|
||||
releaseConcurrencyKey,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +190,22 @@ function getPendingByParent(manager: BackgroundManager): Map<string, Set<string>
|
||||
return (manager as unknown as { pendingByParent: Map<string, Set<string>> }).pendingByParent
|
||||
}
|
||||
|
||||
function getQueuesByKey(
|
||||
manager: BackgroundManager
|
||||
): Map<string, Array<{ task: BackgroundTask; input: import("./types").LaunchInput }>> {
|
||||
return (manager as unknown as {
|
||||
queuesByKey: Map<string, Array<{ task: BackgroundTask; input: import("./types").LaunchInput }>>
|
||||
}).queuesByKey
|
||||
}
|
||||
|
||||
async function processKeyForTest(manager: BackgroundManager, key: string): Promise<void> {
|
||||
return (manager as unknown as { processKey: (key: string) => Promise<void> }).processKey(key)
|
||||
}
|
||||
|
||||
function pruneStaleTasksAndNotificationsForTest(manager: BackgroundManager): void {
|
||||
;(manager as unknown as { pruneStaleTasksAndNotifications: () => void }).pruneStaleTasksAndNotifications()
|
||||
}
|
||||
|
||||
async function tryCompleteTaskForTest(manager: BackgroundManager, task: BackgroundTask): Promise<boolean> {
|
||||
return (manager as unknown as { tryCompleteTask: (task: BackgroundTask, source: string) => Promise<boolean> })
|
||||
.tryCompleteTask(task, "test")
|
||||
@@ -1520,7 +1536,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
})
|
||||
|
||||
describe("task transitions pending→running when slot available", () => {
|
||||
test("should inherit parent session permission rules (and force deny question)", async () => {
|
||||
test("does not override parent session permission when creating child session", async () => {
|
||||
// given
|
||||
const createCalls: any[] = []
|
||||
const parentPermission = [
|
||||
@@ -1562,11 +1578,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
|
||||
// then
|
||||
expect(createCalls).toHaveLength(1)
|
||||
const permission = createCalls[0]?.body?.permission
|
||||
expect(permission).toEqual([
|
||||
{ permission: "plan_enter", action: "deny", pattern: "*" },
|
||||
{ permission: "question", action: "deny", pattern: "*" },
|
||||
])
|
||||
expect(createCalls[0]?.body?.permission).toBeUndefined()
|
||||
})
|
||||
|
||||
test("should transition first task to running immediately", async () => {
|
||||
@@ -2509,6 +2521,198 @@ describe("BackgroundManager.handleEvent - session.deleted cascade", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager.handleEvent - session.error", () => {
|
||||
test("sets task to error, releases concurrency, and cleans up", async () => {
|
||||
//#given
|
||||
const manager = createBackgroundManager()
|
||||
const concurrencyManager = getConcurrencyManager(manager)
|
||||
const concurrencyKey = "test-provider/test-model"
|
||||
await concurrencyManager.acquire(concurrencyKey)
|
||||
|
||||
const sessionID = "ses_error_1"
|
||||
const task = createMockTask({
|
||||
id: "task-session-error",
|
||||
sessionID,
|
||||
parentSessionID: "parent-session",
|
||||
parentMessageID: "msg-1",
|
||||
description: "task that errors",
|
||||
agent: "explore",
|
||||
status: "running",
|
||||
concurrencyKey,
|
||||
})
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
getPendingByParent(manager).set(task.parentSessionID, new Set([task.id]))
|
||||
|
||||
//#when
|
||||
manager.handleEvent({
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
error: {
|
||||
name: "UnknownError",
|
||||
data: { message: "Model not found: kimi-for-coding/k2p5." },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(task.status).toBe("error")
|
||||
expect(task.error).toBe("Model not found: kimi-for-coding/k2p5.")
|
||||
expect(task.completedAt).toBeInstanceOf(Date)
|
||||
expect(concurrencyManager.getCount(concurrencyKey)).toBe(0)
|
||||
expect(getTaskMap(manager).has(task.id)).toBe(false)
|
||||
expect(getPendingByParent(manager).get(task.parentSessionID)).toBeUndefined()
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
test("ignores session.error for non-running tasks", () => {
|
||||
//#given
|
||||
const manager = createBackgroundManager()
|
||||
const sessionID = "ses_error_ignored"
|
||||
const task = createMockTask({
|
||||
id: "task-non-running",
|
||||
sessionID,
|
||||
parentSessionID: "parent-session",
|
||||
parentMessageID: "msg-1",
|
||||
description: "task already done",
|
||||
agent: "explore",
|
||||
status: "completed",
|
||||
})
|
||||
task.completedAt = new Date()
|
||||
task.error = "previous"
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
//#when
|
||||
manager.handleEvent({
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
error: { name: "UnknownError", message: "should not matter" },
|
||||
},
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(task.status).toBe("completed")
|
||||
expect(task.error).toBe("previous")
|
||||
expect(getTaskMap(manager).has(task.id)).toBe(true)
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
test("ignores session.error for unknown session", () => {
|
||||
//#given
|
||||
const manager = createBackgroundManager()
|
||||
|
||||
//#when
|
||||
const handler = () =>
|
||||
manager.handleEvent({
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID: "ses_unknown",
|
||||
error: { name: "UnknownError", message: "Model not found" },
|
||||
},
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(handler).not.toThrow()
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager queue processing - error tasks are skipped", () => {
|
||||
test("does not start tasks with status=error", async () => {
|
||||
//#given
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async () => ({}),
|
||||
promptAsync: async () => ({}),
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager(
|
||||
{ client, directory: tmpdir() } as unknown as PluginInput,
|
||||
{ defaultConcurrency: 1 }
|
||||
)
|
||||
|
||||
const key = "test-key"
|
||||
const task: BackgroundTask = {
|
||||
id: "task-error-queued",
|
||||
parentSessionID: "parent-session",
|
||||
parentMessageID: "msg-1",
|
||||
description: "queued error task",
|
||||
prompt: "test",
|
||||
agent: "test-agent",
|
||||
status: "error",
|
||||
queuedAt: new Date(),
|
||||
}
|
||||
|
||||
const input: import("./types").LaunchInput = {
|
||||
description: task.description,
|
||||
prompt: task.prompt,
|
||||
agent: task.agent,
|
||||
parentSessionID: task.parentSessionID,
|
||||
parentMessageID: task.parentMessageID,
|
||||
}
|
||||
|
||||
let startCalled = false
|
||||
;(manager as unknown as { startTask: (item: unknown) => Promise<void> }).startTask = async () => {
|
||||
startCalled = true
|
||||
}
|
||||
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
getQueuesByKey(manager).set(key, [{ task, input }])
|
||||
|
||||
//#when
|
||||
await processKeyForTest(manager, key)
|
||||
|
||||
//#then
|
||||
expect(startCalled).toBe(false)
|
||||
expect(getQueuesByKey(manager).get(key)?.length ?? 0).toBe(0)
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager.pruneStaleTasksAndNotifications - removes pruned tasks from queuesByKey", () => {
|
||||
test("removes stale pending task from queue", () => {
|
||||
//#given
|
||||
const manager = createBackgroundManager()
|
||||
const queuedAt = new Date(Date.now() - 31 * 60 * 1000)
|
||||
const task: BackgroundTask = {
|
||||
id: "task-stale-pending",
|
||||
parentSessionID: "parent-session",
|
||||
parentMessageID: "msg-1",
|
||||
description: "stale pending",
|
||||
prompt: "test",
|
||||
agent: "test-agent",
|
||||
status: "pending",
|
||||
queuedAt,
|
||||
}
|
||||
const key = task.agent
|
||||
|
||||
const input: import("./types").LaunchInput = {
|
||||
description: task.description,
|
||||
prompt: task.prompt,
|
||||
agent: task.agent,
|
||||
parentSessionID: task.parentSessionID,
|
||||
parentMessageID: task.parentMessageID,
|
||||
}
|
||||
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
getQueuesByKey(manager).set(key, [{ task, input }])
|
||||
|
||||
//#when
|
||||
pruneStaleTasksAndNotificationsForTest(manager)
|
||||
|
||||
//#then
|
||||
expect(getQueuesByKey(manager).get(key)).toBeUndefined()
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
@@ -192,7 +192,7 @@ export class BackgroundManager {
|
||||
|
||||
await this.concurrencyManager.acquire(key)
|
||||
|
||||
if (item.task.status === "cancelled") {
|
||||
if (item.task.status === "cancelled" || item.task.status === "error") {
|
||||
this.concurrencyManager.release(key)
|
||||
queue.shift()
|
||||
continue
|
||||
@@ -236,17 +236,10 @@ export class BackgroundManager {
|
||||
const parentDirectory = parentSession?.data?.directory ?? this.directory
|
||||
log(`[background-agent] Parent dir: ${parentSession?.data?.directory}, using: ${parentDirectory}`)
|
||||
|
||||
const inheritedPermission = (parentSession as any)?.data?.permission
|
||||
const permissionRules = Array.isArray(inheritedPermission)
|
||||
? inheritedPermission.filter((r: any) => r?.permission !== "question")
|
||||
: []
|
||||
permissionRules.push({ permission: "question", action: "deny" as const, pattern: "*" })
|
||||
|
||||
const createResult = await this.client.session.create({
|
||||
body: {
|
||||
parentID: input.parentSessionID,
|
||||
title: `${input.description} (@${input.agent} subagent)`,
|
||||
permission: permissionRules,
|
||||
} as any,
|
||||
query: {
|
||||
directory: parentDirectory,
|
||||
@@ -736,6 +729,44 @@ export class BackgroundManager {
|
||||
})
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const sessionID = typeof props?.sessionID === "string" ? props.sessionID : undefined
|
||||
if (!sessionID) return
|
||||
|
||||
const task = this.findBySession(sessionID)
|
||||
if (!task || task.status !== "running") return
|
||||
|
||||
const errorMessage = props ? this.getSessionErrorMessage(props) : undefined
|
||||
|
||||
task.status = "error"
|
||||
task.error = errorMessage ?? "Session error"
|
||||
task.completedAt = new Date()
|
||||
|
||||
if (task.concurrencyKey) {
|
||||
this.concurrencyManager.release(task.concurrencyKey)
|
||||
task.concurrencyKey = undefined
|
||||
}
|
||||
|
||||
const completionTimer = this.completionTimers.get(task.id)
|
||||
if (completionTimer) {
|
||||
clearTimeout(completionTimer)
|
||||
this.completionTimers.delete(task.id)
|
||||
}
|
||||
|
||||
const idleTimer = this.idleDeferralTimers.get(task.id)
|
||||
if (idleTimer) {
|
||||
clearTimeout(idleTimer)
|
||||
this.idleDeferralTimers.delete(task.id)
|
||||
}
|
||||
|
||||
this.cleanupPendingByParent(task)
|
||||
this.tasks.delete(task.id)
|
||||
this.clearNotificationsForTask(task.id)
|
||||
if (task.sessionID) {
|
||||
subagentSessions.delete(task.sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const info = props?.info
|
||||
if (!info || typeof info.id !== "string") return
|
||||
@@ -1288,6 +1319,24 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
return ""
|
||||
}
|
||||
|
||||
private isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
private getSessionErrorMessage(properties: EventProperties): string | undefined {
|
||||
const errorRaw = properties["error"]
|
||||
if (!this.isRecord(errorRaw)) return undefined
|
||||
|
||||
const dataRaw = errorRaw["data"]
|
||||
if (this.isRecord(dataRaw)) {
|
||||
const message = dataRaw["message"]
|
||||
if (typeof message === "string") return message
|
||||
}
|
||||
|
||||
const message = errorRaw["message"]
|
||||
return typeof message === "string" ? message : undefined
|
||||
}
|
||||
|
||||
private hasRunningTasks(): boolean {
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.status === "running") return true
|
||||
@@ -1299,6 +1348,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
const now = Date.now()
|
||||
|
||||
for (const [taskId, task] of this.tasks.entries()) {
|
||||
const wasPending = task.status === "pending"
|
||||
const timestamp = task.status === "pending"
|
||||
? task.queuedAt?.getTime()
|
||||
: task.startedAt?.getTime()
|
||||
@@ -1323,6 +1373,21 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
}
|
||||
// Clean up pendingByParent to prevent stale entries
|
||||
this.cleanupPendingByParent(task)
|
||||
if (wasPending) {
|
||||
const key = task.model
|
||||
? `${task.model.providerID}/${task.model.modelID}`
|
||||
: task.agent
|
||||
const queue = this.queuesByKey.get(key)
|
||||
if (queue) {
|
||||
const index = queue.findIndex((item) => item.task.id === taskId)
|
||||
if (index !== -1) {
|
||||
queue.splice(index, 1)
|
||||
if (queue.length === 0) {
|
||||
this.queuesByKey.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.clearNotificationsForTask(taskId)
|
||||
this.tasks.delete(taskId)
|
||||
if (task.sessionID) {
|
||||
|
||||
93
src/features/background-agent/session-idle-event-handler.ts
Normal file
93
src/features/background-agent/session-idle-event-handler.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { log } from "../../shared"
|
||||
import { MIN_IDLE_TIME_MS } from "./constants"
|
||||
import type { BackgroundTask } from "./types"
|
||||
|
||||
function getString(obj: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = obj[key]
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
export function handleSessionIdleBackgroundEvent(args: {
|
||||
properties: Record<string, unknown>
|
||||
findBySession: (sessionID: string) => BackgroundTask | undefined
|
||||
idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>>
|
||||
validateSessionHasOutput: (sessionID: string) => Promise<boolean>
|
||||
checkSessionTodos: (sessionID: string) => Promise<boolean>
|
||||
tryCompleteTask: (task: BackgroundTask, source: string) => Promise<boolean>
|
||||
emitIdleEvent: (sessionID: string) => void
|
||||
}): void {
|
||||
const {
|
||||
properties,
|
||||
findBySession,
|
||||
idleDeferralTimers,
|
||||
validateSessionHasOutput,
|
||||
checkSessionTodos,
|
||||
tryCompleteTask,
|
||||
emitIdleEvent,
|
||||
} = args
|
||||
|
||||
const sessionID = getString(properties, "sessionID")
|
||||
if (!sessionID) return
|
||||
|
||||
const task = findBySession(sessionID)
|
||||
if (!task || task.status !== "running") return
|
||||
|
||||
const startedAt = task.startedAt
|
||||
if (!startedAt) return
|
||||
|
||||
const elapsedMs = Date.now() - startedAt.getTime()
|
||||
if (elapsedMs < MIN_IDLE_TIME_MS) {
|
||||
const remainingMs = MIN_IDLE_TIME_MS - elapsedMs
|
||||
if (!idleDeferralTimers.has(task.id)) {
|
||||
log("[background-agent] Deferring early session.idle:", {
|
||||
elapsedMs,
|
||||
remainingMs,
|
||||
taskId: task.id,
|
||||
})
|
||||
const timer = setTimeout(() => {
|
||||
idleDeferralTimers.delete(task.id)
|
||||
emitIdleEvent(sessionID)
|
||||
}, remainingMs)
|
||||
idleDeferralTimers.set(task.id, timer)
|
||||
} else {
|
||||
log("[background-agent] session.idle already deferred:", { elapsedMs, taskId: task.id })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
validateSessionHasOutput(sessionID)
|
||||
.then(async (hasValidOutput) => {
|
||||
if (task.status !== "running") {
|
||||
log("[background-agent] Task status changed during validation, skipping:", {
|
||||
taskId: task.id,
|
||||
status: task.status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasValidOutput) {
|
||||
log("[background-agent] Session.idle but no valid output yet, waiting:", task.id)
|
||||
return
|
||||
}
|
||||
|
||||
const hasIncompleteTodos = await checkSessionTodos(sessionID)
|
||||
|
||||
if (task.status !== "running") {
|
||||
log("[background-agent] Task status changed during todo check, skipping:", {
|
||||
taskId: task.id,
|
||||
status: task.status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (hasIncompleteTodos) {
|
||||
log("[background-agent] Task has incomplete todos, waiting for todo-continuation:", task.id)
|
||||
return
|
||||
}
|
||||
|
||||
await tryCompleteTask(task, "session.idle event")
|
||||
})
|
||||
.catch((err) => {
|
||||
log("[background-agent] Error in session.idle handler:", err)
|
||||
})
|
||||
}
|
||||
46
src/features/background-agent/session-task-cleanup.ts
Normal file
46
src/features/background-agent/session-task-cleanup.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { subagentSessions } from "../claude-code-session-state"
|
||||
import type { BackgroundTask } from "./types"
|
||||
|
||||
export function cleanupTaskAfterSessionEnds(args: {
|
||||
task: BackgroundTask
|
||||
tasks: Map<string, BackgroundTask>
|
||||
idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>>
|
||||
completionTimers: Map<string, ReturnType<typeof setTimeout>>
|
||||
cleanupPendingByParent: (task: BackgroundTask) => void
|
||||
clearNotificationsForTask: (taskId: string) => void
|
||||
releaseConcurrencyKey?: (key: string) => void
|
||||
}): void {
|
||||
const {
|
||||
task,
|
||||
tasks,
|
||||
idleDeferralTimers,
|
||||
completionTimers,
|
||||
cleanupPendingByParent,
|
||||
clearNotificationsForTask,
|
||||
releaseConcurrencyKey,
|
||||
} = args
|
||||
|
||||
const completionTimer = completionTimers.get(task.id)
|
||||
if (completionTimer) {
|
||||
clearTimeout(completionTimer)
|
||||
completionTimers.delete(task.id)
|
||||
}
|
||||
|
||||
const idleTimer = idleDeferralTimers.get(task.id)
|
||||
if (idleTimer) {
|
||||
clearTimeout(idleTimer)
|
||||
idleDeferralTimers.delete(task.id)
|
||||
}
|
||||
|
||||
if (task.concurrencyKey && releaseConcurrencyKey) {
|
||||
releaseConcurrencyKey(task.concurrencyKey)
|
||||
task.concurrencyKey = undefined
|
||||
}
|
||||
|
||||
cleanupPendingByParent(task)
|
||||
clearNotificationsForTask(task.id)
|
||||
tasks.delete(task.id)
|
||||
if (task.sessionID) {
|
||||
subagentSessions.delete(task.sessionID)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { describe, test, expect } from "bun:test"
|
||||
import { createTask, startTask } from "./spawner"
|
||||
|
||||
describe("background-agent spawner.startTask", () => {
|
||||
test("should inherit parent session permission rules (and force deny question)", async () => {
|
||||
test("does not override parent session permission rules when creating child session", async () => {
|
||||
//#given
|
||||
const createCalls: any[] = []
|
||||
const parentPermission = [
|
||||
@@ -57,9 +57,6 @@ describe("background-agent spawner.startTask", () => {
|
||||
|
||||
//#then
|
||||
expect(createCalls).toHaveLength(1)
|
||||
expect(createCalls[0]?.body?.permission).toEqual([
|
||||
{ permission: "plan_enter", action: "deny", pattern: "*" },
|
||||
{ permission: "question", action: "deny", pattern: "*" },
|
||||
])
|
||||
expect(createCalls[0]?.body?.permission).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -58,17 +58,10 @@ export async function startTask(
|
||||
const parentDirectory = parentSession?.data?.directory ?? directory
|
||||
log(`[background-agent] Parent dir: ${parentSession?.data?.directory}, using: ${parentDirectory}`)
|
||||
|
||||
const inheritedPermission = (parentSession as any)?.data?.permission
|
||||
const permissionRules = Array.isArray(inheritedPermission)
|
||||
? inheritedPermission.filter((r: any) => r?.permission !== "question")
|
||||
: []
|
||||
permissionRules.push({ permission: "question", action: "deny" as const, pattern: "*" })
|
||||
|
||||
const createResult = await client.session.create({
|
||||
body: {
|
||||
parentID: input.parentSessionID,
|
||||
title: `Background: ${input.description}`,
|
||||
permission: permissionRules,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
query: {
|
||||
|
||||
@@ -15,7 +15,6 @@ export async function createBackgroundSession(options: {
|
||||
const body = {
|
||||
parentID: input.parentSessionID,
|
||||
title: `Background: ${input.description}`,
|
||||
permission: [{ permission: "question", action: "deny" as const, pattern: "*" }],
|
||||
}
|
||||
|
||||
const createResult = await client.session
|
||||
|
||||
@@ -4,12 +4,15 @@ import { TASK_TTL_MS } from "./constants"
|
||||
import { subagentSessions } from "../claude-code-session-state"
|
||||
import { pruneStaleTasksAndNotifications } from "./task-poller"
|
||||
|
||||
import type { BackgroundTask } from "./types"
|
||||
import type { BackgroundTask, LaunchInput } from "./types"
|
||||
import type { ConcurrencyManager } from "./concurrency"
|
||||
|
||||
type QueueItem = { task: BackgroundTask; input: LaunchInput }
|
||||
|
||||
export function pruneStaleState(args: {
|
||||
tasks: Map<string, BackgroundTask>
|
||||
notifications: Map<string, BackgroundTask[]>
|
||||
queuesByKey: Map<string, QueueItem[]>
|
||||
concurrencyManager: ConcurrencyManager
|
||||
cleanupPendingByParent: (task: BackgroundTask) => void
|
||||
clearNotificationsForTask: (taskId: string) => void
|
||||
@@ -17,6 +20,7 @@ export function pruneStaleState(args: {
|
||||
const {
|
||||
tasks,
|
||||
notifications,
|
||||
queuesByKey,
|
||||
concurrencyManager,
|
||||
cleanupPendingByParent,
|
||||
clearNotificationsForTask,
|
||||
@@ -26,6 +30,7 @@ export function pruneStaleState(args: {
|
||||
tasks,
|
||||
notifications,
|
||||
onTaskPruned: (taskId, task, errorMessage) => {
|
||||
const wasPending = task.status === "pending"
|
||||
const now = Date.now()
|
||||
const timestamp = task.status === "pending"
|
||||
? task.queuedAt?.getTime()
|
||||
@@ -47,6 +52,21 @@ export function pruneStaleState(args: {
|
||||
}
|
||||
|
||||
cleanupPendingByParent(task)
|
||||
if (wasPending) {
|
||||
const key = task.model
|
||||
? `${task.model.providerID}/${task.model.modelID}`
|
||||
: task.agent
|
||||
const queue = queuesByKey.get(key)
|
||||
if (queue) {
|
||||
const index = queue.findIndex((item) => item.task.id === taskId)
|
||||
if (index !== -1) {
|
||||
queue.splice(index, 1)
|
||||
if (queue.length === 0) {
|
||||
queuesByKey.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
clearNotificationsForTask(taskId)
|
||||
tasks.delete(taskId)
|
||||
if (task.sessionID) {
|
||||
|
||||
@@ -27,7 +27,7 @@ export async function processConcurrencyKeyQueue(args: {
|
||||
|
||||
await concurrencyManager.acquire(key)
|
||||
|
||||
if (item.task.status === "cancelled") {
|
||||
if (item.task.status === "cancelled" || item.task.status === "error") {
|
||||
concurrencyManager.release(key)
|
||||
queue.shift()
|
||||
continue
|
||||
|
||||
@@ -69,7 +69,6 @@ export async function startQueuedTask(args: {
|
||||
body: {
|
||||
parentID: input.parentSessionID,
|
||||
title: `${input.description} (@${input.agent} subagent)`,
|
||||
permission: [{ permission: "question", action: "deny" as const, pattern: "*" }],
|
||||
} as any,
|
||||
query: {
|
||||
directory: parentDirectory,
|
||||
|
||||
@@ -43,6 +43,78 @@ describe("boulder-state", () => {
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test("should return null for JSON null value", () => {
|
||||
//#given - boulder.json containing null
|
||||
const boulderFile = join(SISYPHUS_DIR, "boulder.json")
|
||||
writeFileSync(boulderFile, "null")
|
||||
|
||||
//#when
|
||||
const result = readBoulderState(TEST_DIR)
|
||||
|
||||
//#then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test("should return null for JSON primitive value", () => {
|
||||
//#given - boulder.json containing a string
|
||||
const boulderFile = join(SISYPHUS_DIR, "boulder.json")
|
||||
writeFileSync(boulderFile, '"just a string"')
|
||||
|
||||
//#when
|
||||
const result = readBoulderState(TEST_DIR)
|
||||
|
||||
//#then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test("should default session_ids to [] when missing from JSON", () => {
|
||||
//#given - boulder.json without session_ids field
|
||||
const boulderFile = join(SISYPHUS_DIR, "boulder.json")
|
||||
writeFileSync(boulderFile, JSON.stringify({
|
||||
active_plan: "/path/to/plan.md",
|
||||
started_at: "2026-01-01T00:00:00Z",
|
||||
plan_name: "plan",
|
||||
}))
|
||||
|
||||
//#when
|
||||
const result = readBoulderState(TEST_DIR)
|
||||
|
||||
//#then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.session_ids).toEqual([])
|
||||
})
|
||||
|
||||
test("should default session_ids to [] when not an array", () => {
|
||||
//#given - boulder.json with session_ids as a string
|
||||
const boulderFile = join(SISYPHUS_DIR, "boulder.json")
|
||||
writeFileSync(boulderFile, JSON.stringify({
|
||||
active_plan: "/path/to/plan.md",
|
||||
started_at: "2026-01-01T00:00:00Z",
|
||||
session_ids: "not-an-array",
|
||||
plan_name: "plan",
|
||||
}))
|
||||
|
||||
//#when
|
||||
const result = readBoulderState(TEST_DIR)
|
||||
|
||||
//#then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.session_ids).toEqual([])
|
||||
})
|
||||
|
||||
test("should default session_ids to [] for empty object", () => {
|
||||
//#given - boulder.json with empty object
|
||||
const boulderFile = join(SISYPHUS_DIR, "boulder.json")
|
||||
writeFileSync(boulderFile, JSON.stringify({}))
|
||||
|
||||
//#when
|
||||
const result = readBoulderState(TEST_DIR)
|
||||
|
||||
//#then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.session_ids).toEqual([])
|
||||
})
|
||||
|
||||
test("should read valid boulder state", () => {
|
||||
// given - valid boulder.json
|
||||
const state: BoulderState = {
|
||||
@@ -129,6 +201,23 @@ describe("boulder-state", () => {
|
||||
// then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test("should not crash when boulder.json has no session_ids field", () => {
|
||||
//#given - boulder.json without session_ids
|
||||
const boulderFile = join(SISYPHUS_DIR, "boulder.json")
|
||||
writeFileSync(boulderFile, JSON.stringify({
|
||||
active_plan: "/plan.md",
|
||||
started_at: "2026-01-01T00:00:00Z",
|
||||
plan_name: "plan",
|
||||
}))
|
||||
|
||||
//#when
|
||||
const result = appendSessionId(TEST_DIR, "ses-new")
|
||||
|
||||
//#then - should not crash and should contain the new session
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.session_ids).toContain("ses-new")
|
||||
})
|
||||
})
|
||||
|
||||
describe("clearBoulderState", () => {
|
||||
|
||||
@@ -22,7 +22,14 @@ export function readBoulderState(directory: string): BoulderState | null {
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
return JSON.parse(content) as BoulderState
|
||||
const parsed = JSON.parse(content)
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return null
|
||||
}
|
||||
if (!Array.isArray(parsed.session_ids)) {
|
||||
parsed.session_ids = []
|
||||
}
|
||||
return parsed as BoulderState
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -48,7 +55,10 @@ export function appendSessionId(directory: string, sessionId: string): BoulderSt
|
||||
const state = readBoulderState(directory)
|
||||
if (!state) return null
|
||||
|
||||
if (!state.session_ids.includes(sessionId)) {
|
||||
if (!state.session_ids?.includes(sessionId)) {
|
||||
if (!Array.isArray(state.session_ids)) {
|
||||
state.session_ids = []
|
||||
}
|
||||
state.session_ids.push(sessionId)
|
||||
if (writeBoulderState(directory, state)) {
|
||||
return state
|
||||
|
||||
@@ -41,7 +41,7 @@ export function createAtlasEventHandler(input: {
|
||||
|
||||
// Read boulder state FIRST to check if this session is part of an active boulder
|
||||
const boulderState = readBoulderState(ctx.directory)
|
||||
const isBoulderSession = boulderState?.session_ids.includes(sessionID) ?? false
|
||||
const isBoulderSession = boulderState?.session_ids?.includes(sessionID) ?? false
|
||||
|
||||
const isBackgroundTaskSession = subagentSessions.has(sessionID)
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ export function createToolExecuteAfterHandler(input: {
|
||||
if (boulderState) {
|
||||
const progress = getPlanProgress(boulderState.active_plan)
|
||||
|
||||
if (toolInput.sessionID && !boulderState.session_ids.includes(toolInput.sessionID)) {
|
||||
if (toolInput.sessionID && !boulderState.session_ids?.includes(toolInput.sessionID)) {
|
||||
appendSessionId(ctx.directory, toolInput.sessionID)
|
||||
log(`[${HOOK_NAME}] Appended session to boulder`, {
|
||||
sessionID: toolInput.sessionID,
|
||||
|
||||
@@ -26,7 +26,23 @@ export function buildOrchestratorReminder(
|
||||
|
||||
${buildVerificationReminder(sessionId)}
|
||||
|
||||
**STEP 5: CHECK BOULDER STATE DIRECTLY (EVERY TIME — NO EXCEPTIONS)**
|
||||
**STEP 5: READ SUBAGENT NOTEPAD (LEARNINGS, ISSUES, PROBLEMS)**
|
||||
|
||||
The subagent was instructed to record findings in notepad files. Read them NOW:
|
||||
\`\`\`
|
||||
Glob(".sisyphus/notepads/${planName}/*.md")
|
||||
\`\`\`
|
||||
Then \`Read\` each file found — especially:
|
||||
- **learnings.md**: Patterns, conventions, successful approaches discovered
|
||||
- **issues.md**: Problems, blockers, gotchas encountered during work
|
||||
- **problems.md**: Unresolved issues, technical debt flagged
|
||||
|
||||
**USE this information to:**
|
||||
- Inform your next delegation (avoid known pitfalls)
|
||||
- Adjust your plan if blockers were discovered
|
||||
- Propagate learnings to subsequent subagents
|
||||
|
||||
**STEP 6: CHECK BOULDER STATE DIRECTLY (EVERY TIME — NO EXCEPTIONS)**
|
||||
|
||||
Do NOT rely on cached progress. Read the plan file NOW:
|
||||
\`\`\`
|
||||
@@ -35,7 +51,7 @@ Read(".sisyphus/plans/${planName}.md")
|
||||
Count exactly: how many \`- [ ]\` remain? How many \`- [x]\` completed?
|
||||
This is YOUR ground truth. Use it to decide what comes next.
|
||||
|
||||
**STEP 6: MARK COMPLETION IN PLAN FILE (IMMEDIATELY)**
|
||||
**STEP 7: MARK COMPLETION IN PLAN FILE (IMMEDIATELY)**
|
||||
|
||||
RIGHT NOW - Do not delay. Verification passed → Mark IMMEDIATELY.
|
||||
|
||||
@@ -45,12 +61,12 @@ Update the plan file \`.sisyphus/plans/${planName}.md\`:
|
||||
|
||||
**DO THIS BEFORE ANYTHING ELSE. Unmarked = Untracked = Lost progress.**
|
||||
|
||||
**STEP 7: COMMIT ATOMIC UNIT**
|
||||
**STEP 8: COMMIT ATOMIC UNIT**
|
||||
|
||||
- Stage ONLY the verified changes
|
||||
- Commit with clear message describing what was done
|
||||
|
||||
**STEP 8: PROCEED TO NEXT TASK**
|
||||
**STEP 9: PROCEED TO NEXT TASK**
|
||||
|
||||
- Read the plan file AGAIN to identify the next \`- [ ]\` task
|
||||
- Start immediately - DO NOT STOP
|
||||
|
||||
@@ -202,7 +202,7 @@ export async function executeSlashCommand(parsed: ParsedSlashCommand, options?:
|
||||
if (!command) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Command "/${parsed.command}" not found. Use the slashcommand tool to list available commands.`,
|
||||
error: parsed.command.includes(":") ? `Marketplace plugin commands like "/${parsed.command}" are not supported. Use .claude/commands/ for custom commands.` : `Command "/${parsed.command}" not found. Use the slashcommand tool to list available commands.`,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,6 @@ export { getLocalDevVersion } from "./checker/local-dev-version"
|
||||
export { findPluginEntry } from "./checker/plugin-entry"
|
||||
export type { PluginEntryInfo } from "./checker/plugin-entry"
|
||||
export { getCachedVersion } from "./checker/cached-version"
|
||||
export { updatePinnedVersion } from "./checker/pinned-version-updater"
|
||||
export { updatePinnedVersion, revertPinnedVersion } from "./checker/pinned-version-updater"
|
||||
export { getLatestVersion } from "./checker/latest-version"
|
||||
export { checkForUpdate } from "./checker/check-for-update"
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import * as os from "node:os"
|
||||
import { updatePinnedVersion, revertPinnedVersion } from "./pinned-version-updater"
|
||||
|
||||
describe("pinned-version-updater", () => {
|
||||
let tmpDir: string
|
||||
let configPath: string
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omo-updater-test-"))
|
||||
configPath = path.join(tmpDir, "opencode.json")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("updatePinnedVersion", () => {
|
||||
test("updates pinned version in config", () => {
|
||||
//#given
|
||||
const config = JSON.stringify({
|
||||
plugin: ["oh-my-opencode@3.1.8"],
|
||||
})
|
||||
fs.writeFileSync(configPath, config)
|
||||
|
||||
//#when
|
||||
const result = updatePinnedVersion(configPath, "oh-my-opencode@3.1.8", "3.4.0")
|
||||
|
||||
//#then
|
||||
expect(result).toBe(true)
|
||||
const updated = fs.readFileSync(configPath, "utf-8")
|
||||
expect(updated).toContain("oh-my-opencode@3.4.0")
|
||||
expect(updated).not.toContain("oh-my-opencode@3.1.8")
|
||||
})
|
||||
|
||||
test("returns false when entry not found", () => {
|
||||
//#given
|
||||
const config = JSON.stringify({
|
||||
plugin: ["some-other-plugin"],
|
||||
})
|
||||
fs.writeFileSync(configPath, config)
|
||||
|
||||
//#when
|
||||
const result = updatePinnedVersion(configPath, "oh-my-opencode@3.1.8", "3.4.0")
|
||||
|
||||
//#then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false when no plugin array exists", () => {
|
||||
//#given
|
||||
const config = JSON.stringify({ agent: {} })
|
||||
fs.writeFileSync(configPath, config)
|
||||
|
||||
//#when
|
||||
const result = updatePinnedVersion(configPath, "oh-my-opencode@3.1.8", "3.4.0")
|
||||
|
||||
//#then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("revertPinnedVersion", () => {
|
||||
test("reverts from failed version back to original entry", () => {
|
||||
//#given
|
||||
const config = JSON.stringify({
|
||||
plugin: ["oh-my-opencode@3.4.0"],
|
||||
})
|
||||
fs.writeFileSync(configPath, config)
|
||||
|
||||
//#when
|
||||
const result = revertPinnedVersion(configPath, "3.4.0", "oh-my-opencode@3.1.8")
|
||||
|
||||
//#then
|
||||
expect(result).toBe(true)
|
||||
const reverted = fs.readFileSync(configPath, "utf-8")
|
||||
expect(reverted).toContain("oh-my-opencode@3.1.8")
|
||||
expect(reverted).not.toContain("oh-my-opencode@3.4.0")
|
||||
})
|
||||
|
||||
test("reverts to unpinned entry", () => {
|
||||
//#given
|
||||
const config = JSON.stringify({
|
||||
plugin: ["oh-my-opencode@3.4.0"],
|
||||
})
|
||||
fs.writeFileSync(configPath, config)
|
||||
|
||||
//#when
|
||||
const result = revertPinnedVersion(configPath, "3.4.0", "oh-my-opencode")
|
||||
|
||||
//#then
|
||||
expect(result).toBe(true)
|
||||
const reverted = fs.readFileSync(configPath, "utf-8")
|
||||
expect(reverted).toContain('"oh-my-opencode"')
|
||||
expect(reverted).not.toContain("oh-my-opencode@3.4.0")
|
||||
})
|
||||
|
||||
test("returns false when failed version not found", () => {
|
||||
//#given
|
||||
const config = JSON.stringify({
|
||||
plugin: ["oh-my-opencode@3.1.8"],
|
||||
})
|
||||
fs.writeFileSync(configPath, config)
|
||||
|
||||
//#when
|
||||
const result = revertPinnedVersion(configPath, "3.4.0", "oh-my-opencode@3.1.8")
|
||||
|
||||
//#then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("update then revert roundtrip", () => {
|
||||
test("config returns to original state after update + revert", () => {
|
||||
//#given
|
||||
const originalConfig = JSON.stringify({
|
||||
plugin: ["oh-my-opencode@3.1.8"],
|
||||
})
|
||||
fs.writeFileSync(configPath, originalConfig)
|
||||
|
||||
//#when
|
||||
updatePinnedVersion(configPath, "oh-my-opencode@3.1.8", "3.4.0")
|
||||
revertPinnedVersion(configPath, "3.4.0", "oh-my-opencode@3.1.8")
|
||||
|
||||
//#then
|
||||
const finalConfig = fs.readFileSync(configPath, "utf-8")
|
||||
expect(finalConfig).toContain("oh-my-opencode@3.1.8")
|
||||
expect(finalConfig).not.toContain("oh-my-opencode@3.4.0")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,10 +2,9 @@ import * as fs from "node:fs"
|
||||
import { log } from "../../../shared/logger"
|
||||
import { PACKAGE_NAME } from "../constants"
|
||||
|
||||
export function updatePinnedVersion(configPath: string, oldEntry: string, newVersion: string): boolean {
|
||||
function replacePluginEntry(configPath: string, oldEntry: string, newEntry: string): boolean {
|
||||
try {
|
||||
const content = fs.readFileSync(configPath, "utf-8")
|
||||
const newEntry = `${PACKAGE_NAME}@${newVersion}`
|
||||
|
||||
const pluginMatch = content.match(/"plugin"\s*:\s*\[/)
|
||||
if (!pluginMatch || pluginMatch.index === undefined) {
|
||||
@@ -51,3 +50,13 @@ export function updatePinnedVersion(configPath: string, oldEntry: string, newVer
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function updatePinnedVersion(configPath: string, oldEntry: string, newVersion: string): boolean {
|
||||
const newEntry = `${PACKAGE_NAME}@${newVersion}`
|
||||
return replacePluginEntry(configPath, oldEntry, newEntry)
|
||||
}
|
||||
|
||||
export function revertPinnedVersion(configPath: string, failedVersion: string, originalEntry: string): boolean {
|
||||
const failedEntry = `${PACKAGE_NAME}@${failedVersion}`
|
||||
return replacePluginEntry(configPath, failedEntry, originalEntry)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, it, expect, mock, beforeEach } from "bun:test"
|
||||
|
||||
// Mock modules before importing
|
||||
const mockFindPluginEntry = mock(() => null as any)
|
||||
const mockGetCachedVersion = mock(() => null as string | null)
|
||||
const mockGetLatestVersion = mock(async () => null as string | null)
|
||||
const mockUpdatePinnedVersion = mock(() => false)
|
||||
const mockExtractChannel = mock(() => "latest")
|
||||
const mockInvalidatePackage = mock(() => {})
|
||||
const mockRunBunInstall = mock(async () => true)
|
||||
const mockShowUpdateAvailableToast = mock(async () => {})
|
||||
const mockShowAutoUpdatedToast = mock(async () => {})
|
||||
|
||||
mock.module("../checker", () => ({
|
||||
findPluginEntry: mockFindPluginEntry,
|
||||
getCachedVersion: mockGetCachedVersion,
|
||||
getLatestVersion: mockGetLatestVersion,
|
||||
updatePinnedVersion: mockUpdatePinnedVersion,
|
||||
}))
|
||||
|
||||
mock.module("../version-channel", () => ({
|
||||
extractChannel: mockExtractChannel,
|
||||
}))
|
||||
|
||||
mock.module("../cache", () => ({
|
||||
invalidatePackage: mockInvalidatePackage,
|
||||
}))
|
||||
|
||||
mock.module("../../../cli/config-manager", () => ({
|
||||
runBunInstall: mockRunBunInstall,
|
||||
}))
|
||||
|
||||
mock.module("./update-toasts", () => ({
|
||||
showUpdateAvailableToast: mockShowUpdateAvailableToast,
|
||||
showAutoUpdatedToast: mockShowAutoUpdatedToast,
|
||||
}))
|
||||
|
||||
mock.module("../../../shared/logger", () => ({
|
||||
log: () => {},
|
||||
}))
|
||||
|
||||
const { runBackgroundUpdateCheck } = await import("./background-update-check")
|
||||
|
||||
describe("runBackgroundUpdateCheck", () => {
|
||||
const mockCtx = { directory: "/test" } as any
|
||||
const mockGetToastMessage = (isUpdate: boolean, version?: string) =>
|
||||
isUpdate ? `Update to ${version}` : "Up to date"
|
||||
|
||||
beforeEach(() => {
|
||||
mockFindPluginEntry.mockReset()
|
||||
mockGetCachedVersion.mockReset()
|
||||
mockGetLatestVersion.mockReset()
|
||||
mockUpdatePinnedVersion.mockReset()
|
||||
mockExtractChannel.mockReset()
|
||||
mockInvalidatePackage.mockReset()
|
||||
mockRunBunInstall.mockReset()
|
||||
mockShowUpdateAvailableToast.mockReset()
|
||||
mockShowAutoUpdatedToast.mockReset()
|
||||
|
||||
mockExtractChannel.mockReturnValue("latest")
|
||||
mockRunBunInstall.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
describe("#given user has pinned a specific version", () => {
|
||||
beforeEach(() => {
|
||||
mockFindPluginEntry.mockReturnValue({
|
||||
entry: "oh-my-opencode@3.4.0",
|
||||
isPinned: true,
|
||||
pinnedVersion: "3.4.0",
|
||||
configPath: "/test/opencode.json",
|
||||
})
|
||||
mockGetCachedVersion.mockReturnValue("3.4.0")
|
||||
mockGetLatestVersion.mockResolvedValue("3.5.0")
|
||||
})
|
||||
|
||||
it("#then should NOT call updatePinnedVersion", async () => {
|
||||
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
|
||||
|
||||
expect(mockUpdatePinnedVersion).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("#then should show update-available toast instead", async () => {
|
||||
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
|
||||
|
||||
expect(mockShowUpdateAvailableToast).toHaveBeenCalledWith(
|
||||
mockCtx,
|
||||
"3.5.0",
|
||||
mockGetToastMessage
|
||||
)
|
||||
})
|
||||
|
||||
it("#then should NOT run bun install", async () => {
|
||||
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
|
||||
|
||||
expect(mockRunBunInstall).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("#then should NOT invalidate package cache", async () => {
|
||||
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
|
||||
|
||||
expect(mockInvalidatePackage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given user has NOT pinned a version (unpinned)", () => {
|
||||
beforeEach(() => {
|
||||
mockFindPluginEntry.mockReturnValue({
|
||||
entry: "oh-my-opencode",
|
||||
isPinned: false,
|
||||
pinnedVersion: null,
|
||||
configPath: "/test/opencode.json",
|
||||
})
|
||||
mockGetCachedVersion.mockReturnValue("3.4.0")
|
||||
mockGetLatestVersion.mockResolvedValue("3.5.0")
|
||||
})
|
||||
|
||||
it("#then should proceed with auto-update", async () => {
|
||||
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
|
||||
|
||||
expect(mockInvalidatePackage).toHaveBeenCalled()
|
||||
expect(mockRunBunInstall).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("#then should show auto-updated toast on success", async () => {
|
||||
mockRunBunInstall.mockResolvedValue(true)
|
||||
|
||||
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
|
||||
|
||||
expect(mockShowAutoUpdatedToast).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given autoUpdate is false", () => {
|
||||
beforeEach(() => {
|
||||
mockFindPluginEntry.mockReturnValue({
|
||||
entry: "oh-my-opencode",
|
||||
isPinned: false,
|
||||
pinnedVersion: null,
|
||||
configPath: "/test/opencode.json",
|
||||
})
|
||||
mockGetCachedVersion.mockReturnValue("3.4.0")
|
||||
mockGetLatestVersion.mockResolvedValue("3.5.0")
|
||||
})
|
||||
|
||||
it("#then should only show notification toast", async () => {
|
||||
await runBackgroundUpdateCheck(mockCtx, false, mockGetToastMessage)
|
||||
|
||||
expect(mockShowUpdateAvailableToast).toHaveBeenCalled()
|
||||
expect(mockRunBunInstall).not.toHaveBeenCalled()
|
||||
expect(mockUpdatePinnedVersion).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given already on latest version", () => {
|
||||
beforeEach(() => {
|
||||
mockFindPluginEntry.mockReturnValue({
|
||||
entry: "oh-my-opencode@3.5.0",
|
||||
isPinned: true,
|
||||
pinnedVersion: "3.5.0",
|
||||
configPath: "/test/opencode.json",
|
||||
})
|
||||
mockGetCachedVersion.mockReturnValue("3.5.0")
|
||||
mockGetLatestVersion.mockResolvedValue("3.5.0")
|
||||
})
|
||||
|
||||
it("#then should not update or show toast", async () => {
|
||||
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
|
||||
|
||||
expect(mockUpdatePinnedVersion).not.toHaveBeenCalled()
|
||||
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
|
||||
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import { log } from "../../../shared/logger"
|
||||
import { invalidatePackage } from "../cache"
|
||||
import { PACKAGE_NAME } from "../constants"
|
||||
import { extractChannel } from "../version-channel"
|
||||
import { findPluginEntry, getCachedVersion, getLatestVersion, updatePinnedVersion } from "../checker"
|
||||
import { findPluginEntry, getCachedVersion, getLatestVersion, updatePinnedVersion, revertPinnedVersion } from "../checker"
|
||||
import { showAutoUpdatedToast, showUpdateAvailableToast } from "./update-toasts"
|
||||
|
||||
async function runBunInstallSafe(): Promise<boolean> {
|
||||
@@ -56,13 +56,9 @@ export async function runBackgroundUpdateCheck(
|
||||
}
|
||||
|
||||
if (pluginInfo.isPinned) {
|
||||
const updated = updatePinnedVersion(pluginInfo.configPath, pluginInfo.entry, latestVersion)
|
||||
if (!updated) {
|
||||
await showUpdateAvailableToast(ctx, latestVersion, getToastMessage)
|
||||
log("[auto-update-checker] Failed to update pinned version in config")
|
||||
return
|
||||
}
|
||||
log(`[auto-update-checker] Config updated: ${pluginInfo.entry} → ${PACKAGE_NAME}@${latestVersion}`)
|
||||
await showUpdateAvailableToast(ctx, latestVersion, getToastMessage)
|
||||
log(`[auto-update-checker] User-pinned version detected (${pluginInfo.entry}), skipping auto-update. Notification only.`)
|
||||
return
|
||||
}
|
||||
|
||||
invalidatePackage(PACKAGE_NAME)
|
||||
@@ -72,8 +68,14 @@ export async function runBackgroundUpdateCheck(
|
||||
if (installSuccess) {
|
||||
await showAutoUpdatedToast(ctx, currentVersion, latestVersion)
|
||||
log(`[auto-update-checker] Update installed: ${currentVersion} → ${latestVersion}`)
|
||||
} else {
|
||||
await showUpdateAvailableToast(ctx, latestVersion, getToastMessage)
|
||||
log("[auto-update-checker] bun install failed; update not installed (falling back to notification-only)")
|
||||
return
|
||||
}
|
||||
|
||||
if (pluginInfo.isPinned) {
|
||||
revertPinnedVersion(pluginInfo.configPath, latestVersion, pluginInfo.entry)
|
||||
log("[auto-update-checker] Config reverted due to install failure")
|
||||
}
|
||||
|
||||
await showUpdateAvailableToast(ctx, latestVersion, getToastMessage)
|
||||
log("[auto-update-checker] bun install failed; update not installed (falling back to notification-only)")
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ export function createCommentCheckerHooks(config?: CommentCheckerConfig) {
|
||||
const toolLower = input.tool.toLowerCase()
|
||||
|
||||
// Only skip if the output indicates a tool execution failure
|
||||
const outputLower = output.output.toLowerCase()
|
||||
const outputLower = (output.output ?? "").toLowerCase()
|
||||
const isToolFailure =
|
||||
outputLower.includes("error:") ||
|
||||
outputLower.includes("failed to") ||
|
||||
|
||||
@@ -44,7 +44,7 @@ export function createEditErrorRecoveryHook(_ctx: PluginInput) {
|
||||
) => {
|
||||
if (input.tool.toLowerCase() !== "edit") return
|
||||
|
||||
const outputLower = output.output.toLowerCase()
|
||||
const outputLower = (output.output ?? "").toLowerCase()
|
||||
const hasEditError = EDIT_ERROR_PATTERNS.some((pattern) =>
|
||||
outputLower.includes(pattern.toLowerCase())
|
||||
)
|
||||
|
||||
@@ -102,6 +102,23 @@ describe("createEditErrorRecoveryHook", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given MCP tool with undefined output.output", () => {
|
||||
describe("#when output.output is undefined", () => {
|
||||
it("#then should not crash", async () => {
|
||||
const input = createInput("Edit")
|
||||
const output = {
|
||||
title: "Edit",
|
||||
output: undefined as unknown as string,
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
expect(output.output).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given case insensitive tool name", () => {
|
||||
describe("#when tool is 'edit' lowercase", () => {
|
||||
it("#then should still detect and append reminder", async () => {
|
||||
|
||||
5
src/hooks/prometheus-md-only/agent-matcher.ts
Normal file
5
src/hooks/prometheus-md-only/agent-matcher.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PROMETHEUS_AGENT } from "./constants"
|
||||
|
||||
export function isPrometheusAgent(agentName: string | undefined): boolean {
|
||||
return agentName?.toLowerCase().includes(PROMETHEUS_AGENT) ?? false
|
||||
}
|
||||
@@ -43,7 +43,7 @@ export function getAgentFromSession(sessionID: string, directory: string): strin
|
||||
|
||||
// Check boulder state (persisted across restarts) - fixes #927
|
||||
const boulderState = readBoulderState(directory)
|
||||
if (boulderState?.session_ids.includes(sessionID) && boulderState.agent) {
|
||||
if (boulderState?.session_ids?.includes(sessionID) && boulderState.agent) {
|
||||
return boulderState.agent
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { HOOK_NAME, PROMETHEUS_AGENT, BLOCKED_TOOLS, PLANNING_CONSULT_WARNING, PROMETHEUS_WORKFLOW_REMINDER } from "./constants"
|
||||
import { HOOK_NAME, BLOCKED_TOOLS, PLANNING_CONSULT_WARNING, PROMETHEUS_WORKFLOW_REMINDER } from "./constants"
|
||||
import { log } from "../../shared/logger"
|
||||
import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive"
|
||||
import { getAgentDisplayName } from "../../shared/agent-display-names"
|
||||
import { getAgentFromSession } from "./agent-resolution"
|
||||
import { isPrometheusAgent } from "./agent-matcher"
|
||||
import { isAllowedFile } from "./path-policy"
|
||||
|
||||
const TASK_TOOLS = ["task", "call_omo_agent"]
|
||||
@@ -16,7 +17,7 @@ export function createPrometheusMdOnlyHook(ctx: PluginInput) {
|
||||
): Promise<void> => {
|
||||
const agentName = getAgentFromSession(input.sessionID, ctx.directory)
|
||||
|
||||
if (agentName !== PROMETHEUS_AGENT) {
|
||||
if (!isPrometheusAgent(agentName)) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -30,11 +30,11 @@ describe("prometheus-md-only", () => {
|
||||
} as never
|
||||
}
|
||||
|
||||
function setupMessageStorage(sessionID: string, agent: string): void {
|
||||
function setupMessageStorage(sessionID: string, agent: string | undefined): void {
|
||||
testMessageDir = join(MESSAGE_STORAGE, sessionID)
|
||||
mkdirSync(testMessageDir, { recursive: true })
|
||||
const messageContent = {
|
||||
agent,
|
||||
...(agent ? { agent } : {}),
|
||||
model: { providerID: "test", modelID: "test-model" },
|
||||
}
|
||||
writeFileSync(
|
||||
@@ -55,6 +55,122 @@ describe("prometheus-md-only", () => {
|
||||
rmSync(TEST_STORAGE_ROOT, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("agent name matching", () => {
|
||||
test("should enforce md-only restriction for exact prometheus agent name", async () => {
|
||||
//#given
|
||||
setupMessageStorage(TEST_SESSION_ID, "prometheus")
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "/path/to/file.ts" },
|
||||
}
|
||||
|
||||
//#when //#then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("can only write/edit .md files")
|
||||
})
|
||||
|
||||
test("should enforce md-only restriction for Prometheus display name Plan Builder", async () => {
|
||||
//#given
|
||||
setupMessageStorage(TEST_SESSION_ID, "Prometheus (Plan Builder)")
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "/path/to/file.ts" },
|
||||
}
|
||||
|
||||
//#when //#then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("can only write/edit .md files")
|
||||
})
|
||||
|
||||
test("should enforce md-only restriction for Prometheus display name Planner", async () => {
|
||||
//#given
|
||||
setupMessageStorage(TEST_SESSION_ID, "Prometheus (Planner)")
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "/path/to/file.ts" },
|
||||
}
|
||||
|
||||
//#when //#then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("can only write/edit .md files")
|
||||
})
|
||||
|
||||
test("should enforce md-only restriction for uppercase PROMETHEUS", async () => {
|
||||
//#given
|
||||
setupMessageStorage(TEST_SESSION_ID, "PROMETHEUS")
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "/path/to/file.ts" },
|
||||
}
|
||||
|
||||
//#when //#then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("can only write/edit .md files")
|
||||
})
|
||||
|
||||
test("should not enforce restriction for non-Prometheus agent", async () => {
|
||||
//#given
|
||||
setupMessageStorage(TEST_SESSION_ID, "sisyphus")
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "/path/to/file.ts" },
|
||||
}
|
||||
|
||||
//#when //#then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("should not enforce restriction when agent name is undefined", async () => {
|
||||
//#given
|
||||
setupMessageStorage(TEST_SESSION_ID, undefined)
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "/path/to/file.ts" },
|
||||
}
|
||||
|
||||
//#when //#then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("with Prometheus agent in message storage", () => {
|
||||
beforeEach(() => {
|
||||
setupMessageStorage(TEST_SESSION_ID, "prometheus")
|
||||
|
||||
@@ -21,14 +21,15 @@ export function createTaskResumeInfoHook() {
|
||||
output: { title: string; output: string; metadata: unknown }
|
||||
) => {
|
||||
if (!TARGET_TOOLS.includes(input.tool)) return
|
||||
if (output.output.startsWith("Error:") || output.output.startsWith("Failed")) return
|
||||
if (output.output.includes("\nto continue:")) return
|
||||
const outputText = output.output ?? ""
|
||||
if (outputText.startsWith("Error:") || outputText.startsWith("Failed")) return
|
||||
if (outputText.includes("\nto continue:")) return
|
||||
|
||||
const sessionId = extractSessionId(output.output)
|
||||
const sessionId = extractSessionId(outputText)
|
||||
if (!sessionId) return
|
||||
|
||||
output.output =
|
||||
output.output.trimEnd() +
|
||||
outputText.trimEnd() +
|
||||
`\n\nto continue: task(session_id="${sessionId}", prompt="...")`
|
||||
}
|
||||
|
||||
|
||||
101
src/hooks/task-resume-info/index.test.ts
Normal file
101
src/hooks/task-resume-info/index.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { createTaskResumeInfoHook } from "./index"
|
||||
|
||||
describe("createTaskResumeInfoHook", () => {
|
||||
const hook = createTaskResumeInfoHook()
|
||||
const afterHook = hook["tool.execute.after"]
|
||||
|
||||
const createInput = (tool: string) => ({
|
||||
tool,
|
||||
sessionID: "test-session",
|
||||
callID: "test-call-id",
|
||||
})
|
||||
|
||||
describe("#given MCP tool with undefined output.output", () => {
|
||||
describe("#when tool.execute.after is called", () => {
|
||||
it("#then should not crash", async () => {
|
||||
const input = createInput("task")
|
||||
const output = {
|
||||
title: "delegate_task",
|
||||
output: undefined as unknown as string,
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
await afterHook(input, output)
|
||||
|
||||
expect(output.output).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given non-target tool", () => {
|
||||
describe("#when tool is not in TARGET_TOOLS", () => {
|
||||
it("#then should not modify output", async () => {
|
||||
const input = createInput("Read")
|
||||
const output = {
|
||||
title: "Read",
|
||||
output: "some output",
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
await afterHook(input, output)
|
||||
|
||||
expect(output.output).toBe("some output")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given target tool with session ID in output", () => {
|
||||
describe("#when output contains a session ID", () => {
|
||||
it("#then should append resume info", async () => {
|
||||
const input = createInput("call_omo_agent")
|
||||
const output = {
|
||||
title: "delegate_task",
|
||||
output: "Task completed.\nSession ID: ses_abc123",
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
await afterHook(input, output)
|
||||
|
||||
expect(output.output).toContain("to continue:")
|
||||
expect(output.output).toContain("ses_abc123")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given target tool with error output", () => {
|
||||
describe("#when output starts with Error:", () => {
|
||||
it("#then should not modify output", async () => {
|
||||
const input = createInput("task")
|
||||
const output = {
|
||||
title: "task",
|
||||
output: "Error: something went wrong",
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
await afterHook(input, output)
|
||||
|
||||
expect(output.output).toBe("Error: something went wrong")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given target tool with already-continued output", () => {
|
||||
describe("#when output already contains continuation info", () => {
|
||||
it("#then should not add duplicate", async () => {
|
||||
const input = createInput("task")
|
||||
const output = {
|
||||
title: "task",
|
||||
output:
|
||||
'Done.\nSession ID: ses_abc123\nto continue: task(session_id="ses_abc123", prompt="...")',
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
await afterHook(input, output)
|
||||
|
||||
const matches = output.output.match(/to continue:/g)
|
||||
expect(matches?.length).toBe(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,19 +1,14 @@
|
||||
import type { AvailableCategory } from "../agents/dynamic-agent-prompt-builder"
|
||||
import type { OhMyOpenCodeConfig } from "../config"
|
||||
|
||||
import {
|
||||
CATEGORY_DESCRIPTIONS,
|
||||
DEFAULT_CATEGORIES,
|
||||
} from "../tools/delegate-task/constants"
|
||||
import { CATEGORY_DESCRIPTIONS } from "../tools/delegate-task/constants"
|
||||
import { mergeCategories } from "../shared/merge-categories"
|
||||
|
||||
export function createAvailableCategories(
|
||||
pluginConfig: OhMyOpenCodeConfig,
|
||||
): AvailableCategory[] {
|
||||
const mergedCategories = pluginConfig.categories
|
||||
? { ...DEFAULT_CATEGORIES, ...pluginConfig.categories }
|
||||
: DEFAULT_CATEGORIES
|
||||
const categories = mergeCategories(pluginConfig.categories)
|
||||
|
||||
return Object.entries(mergedCategories).map(([name, categoryConfig]) => {
|
||||
return Object.entries(categories).map(([name, categoryConfig]) => {
|
||||
const model =
|
||||
typeof categoryConfig.model === "string" ? categoryConfig.model : undefined
|
||||
|
||||
|
||||
84
src/shared/merge-categories.test.ts
Normal file
84
src/shared/merge-categories.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { mergeCategories } from "./merge-categories"
|
||||
import { DEFAULT_CATEGORIES } from "../tools/delegate-task/constants"
|
||||
|
||||
describe("mergeCategories", () => {
|
||||
it("returns all default categories when no user config provided", () => {
|
||||
//#given
|
||||
const userCategories = undefined
|
||||
|
||||
//#when
|
||||
const result = mergeCategories(userCategories)
|
||||
|
||||
//#then
|
||||
expect(Object.keys(result)).toEqual(Object.keys(DEFAULT_CATEGORIES))
|
||||
})
|
||||
|
||||
it("filters out categories with disable: true", () => {
|
||||
//#given
|
||||
const userCategories = {
|
||||
"quick": { disable: true },
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = mergeCategories(userCategories)
|
||||
|
||||
//#then
|
||||
expect(result["quick"]).toBeUndefined()
|
||||
expect(Object.keys(result).length).toBe(Object.keys(DEFAULT_CATEGORIES).length - 1)
|
||||
})
|
||||
|
||||
it("keeps categories with disable: false", () => {
|
||||
//#given
|
||||
const userCategories = {
|
||||
"quick": { disable: false },
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = mergeCategories(userCategories)
|
||||
|
||||
//#then
|
||||
expect(result["quick"]).toBeDefined()
|
||||
})
|
||||
|
||||
it("allows user to add custom categories", () => {
|
||||
//#given
|
||||
const userCategories = {
|
||||
"my-custom": { model: "openai/gpt-5.2", description: "Custom category" },
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = mergeCategories(userCategories)
|
||||
|
||||
//#then
|
||||
expect(result["my-custom"]).toBeDefined()
|
||||
expect(result["my-custom"].model).toBe("openai/gpt-5.2")
|
||||
})
|
||||
|
||||
it("allows user to disable custom categories", () => {
|
||||
//#given
|
||||
const userCategories = {
|
||||
"my-custom": { model: "openai/gpt-5.2", disable: true },
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = mergeCategories(userCategories)
|
||||
|
||||
//#then
|
||||
expect(result["my-custom"]).toBeUndefined()
|
||||
})
|
||||
|
||||
it("user overrides merge with defaults", () => {
|
||||
//#given
|
||||
const userCategories = {
|
||||
"ultrabrain": { model: "anthropic/claude-opus-4-6" },
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = mergeCategories(userCategories)
|
||||
|
||||
//#then
|
||||
expect(result["ultrabrain"]).toBeDefined()
|
||||
expect(result["ultrabrain"].model).toBe("anthropic/claude-opus-4-6")
|
||||
})
|
||||
})
|
||||
18
src/shared/merge-categories.ts
Normal file
18
src/shared/merge-categories.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { CategoriesConfig, CategoryConfig } from "../config/schema"
|
||||
import { DEFAULT_CATEGORIES } from "../tools/delegate-task/constants"
|
||||
|
||||
/**
|
||||
* Merge default and user categories, filtering out disabled ones.
|
||||
* Single source of truth for category merging across the codebase.
|
||||
*/
|
||||
export function mergeCategories(
|
||||
userCategories?: CategoriesConfig,
|
||||
): Record<string, CategoryConfig> {
|
||||
const merged = userCategories
|
||||
? { ...DEFAULT_CATEGORIES, ...userCategories }
|
||||
: { ...DEFAULT_CATEGORIES }
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(merged).filter(([, config]) => !config.disable),
|
||||
)
|
||||
}
|
||||
@@ -53,24 +53,194 @@ describe("opencode-server-auth", () => {
|
||||
process.env.OPENCODE_SERVER_PASSWORD = "secret"
|
||||
delete process.env.OPENCODE_SERVER_USERNAME
|
||||
|
||||
let receivedConfig: { headers: Record<string, string> } | undefined
|
||||
let receivedHeadersConfig: { headers: Record<string, string> } | undefined
|
||||
const client = {
|
||||
_client: {
|
||||
setConfig: (config: { headers: Record<string, string> }) => {
|
||||
receivedConfig = config
|
||||
setConfig: (config: { headers?: Record<string, string> }) => {
|
||||
if (config.headers) {
|
||||
receivedHeadersConfig = { headers: config.headers }
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
injectServerAuthIntoClient(client)
|
||||
|
||||
expect(receivedConfig).toEqual({
|
||||
expect(receivedHeadersConfig).toEqual({
|
||||
headers: {
|
||||
Authorization: "Basic b3BlbmNvZGU6c2VjcmV0",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("#given server password #when injecting wraps internal fetch #then wrapped fetch adds Authorization header", async () => {
|
||||
//#given
|
||||
process.env.OPENCODE_SERVER_PASSWORD = "secret"
|
||||
delete process.env.OPENCODE_SERVER_USERNAME
|
||||
|
||||
let receivedAuthorization: string | null = null
|
||||
const baseFetch = async (request: Request): Promise<Response> => {
|
||||
receivedAuthorization = request.headers.get("Authorization")
|
||||
return new Response("ok")
|
||||
}
|
||||
|
||||
type InternalConfig = {
|
||||
fetch?: (request: Request) => Promise<Response>
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
let currentConfig: InternalConfig = {
|
||||
fetch: baseFetch,
|
||||
headers: {},
|
||||
}
|
||||
|
||||
const client = {
|
||||
_client: {
|
||||
getConfig: (): InternalConfig => ({ ...currentConfig }),
|
||||
setConfig: (config: InternalConfig): InternalConfig => {
|
||||
currentConfig = { ...currentConfig, ...config }
|
||||
return { ...currentConfig }
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
injectServerAuthIntoClient(client)
|
||||
if (!currentConfig.fetch) {
|
||||
throw new Error("expected fetch to be set")
|
||||
}
|
||||
await currentConfig.fetch(new Request("http://example.com"))
|
||||
|
||||
//#then
|
||||
expect(receivedAuthorization ?? "").toBe("Basic b3BlbmNvZGU6c2VjcmV0")
|
||||
})
|
||||
|
||||
test("#given server password #when internal has _config.fetch but no setConfig #then fetch is wrapped and injects Authorization", async () => {
|
||||
//#given
|
||||
process.env.OPENCODE_SERVER_PASSWORD = "secret"
|
||||
delete process.env.OPENCODE_SERVER_USERNAME
|
||||
|
||||
let receivedAuthorization: string | null = null
|
||||
const baseFetch = async (request: Request): Promise<Response> => {
|
||||
receivedAuthorization = request.headers.get("Authorization")
|
||||
return new Response("ok")
|
||||
}
|
||||
|
||||
const internal = {
|
||||
_config: {
|
||||
fetch: baseFetch,
|
||||
},
|
||||
}
|
||||
|
||||
const client = {
|
||||
_client: internal,
|
||||
}
|
||||
|
||||
//#when
|
||||
injectServerAuthIntoClient(client)
|
||||
await internal._config.fetch(new Request("http://example.com"))
|
||||
|
||||
//#then
|
||||
expect(receivedAuthorization ?? "").toBe("Basic b3BlbmNvZGU6c2VjcmV0")
|
||||
})
|
||||
|
||||
test("#given server password #when client has top-level fetch #then fetch is wrapped and injects Authorization", async () => {
|
||||
//#given
|
||||
process.env.OPENCODE_SERVER_PASSWORD = "secret"
|
||||
delete process.env.OPENCODE_SERVER_USERNAME
|
||||
|
||||
let receivedAuthorization: string | null = null
|
||||
const baseFetch = async (request: Request): Promise<Response> => {
|
||||
receivedAuthorization = request.headers.get("Authorization")
|
||||
return new Response("ok")
|
||||
}
|
||||
|
||||
const client = {
|
||||
fetch: baseFetch,
|
||||
}
|
||||
|
||||
//#when
|
||||
injectServerAuthIntoClient(client)
|
||||
await client.fetch(new Request("http://example.com"))
|
||||
|
||||
//#then
|
||||
expect(receivedAuthorization ?? "").toBe("Basic b3BlbmNvZGU6c2VjcmV0")
|
||||
})
|
||||
|
||||
test("#given server password #when interceptors are available #then request interceptor injects Authorization", async () => {
|
||||
//#given
|
||||
process.env.OPENCODE_SERVER_PASSWORD = "secret"
|
||||
delete process.env.OPENCODE_SERVER_USERNAME
|
||||
|
||||
let registeredInterceptor:
|
||||
| ((request: Request, options: { headers?: Headers }) => Promise<Request> | Request)
|
||||
| undefined
|
||||
|
||||
const client = {
|
||||
_client: {
|
||||
interceptors: {
|
||||
request: {
|
||||
use: (
|
||||
interceptor: (request: Request, options: { headers?: Headers }) => Promise<Request> | Request
|
||||
): number => {
|
||||
registeredInterceptor = interceptor
|
||||
return 0
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
injectServerAuthIntoClient(client)
|
||||
if (!registeredInterceptor) {
|
||||
throw new Error("expected interceptor to be registered")
|
||||
}
|
||||
const request = new Request("http://example.com")
|
||||
const result = await registeredInterceptor(request, {})
|
||||
|
||||
//#then
|
||||
expect(result.headers.get("Authorization")).toBe("Basic b3BlbmNvZGU6c2VjcmV0")
|
||||
})
|
||||
|
||||
test("#given no server password #when injecting into client with fetch #then does not wrap fetch", async () => {
|
||||
//#given
|
||||
delete process.env.OPENCODE_SERVER_PASSWORD
|
||||
delete process.env.OPENCODE_SERVER_USERNAME
|
||||
|
||||
let receivedAuthorization: string | null = null
|
||||
const baseFetch = async (request: Request): Promise<Response> => {
|
||||
receivedAuthorization = request.headers.get("Authorization")
|
||||
return new Response("ok")
|
||||
}
|
||||
|
||||
type InternalConfig = { fetch?: (request: Request) => Promise<Response> }
|
||||
let currentConfig: InternalConfig = { fetch: baseFetch }
|
||||
let setConfigCalled = false
|
||||
|
||||
const client = {
|
||||
_client: {
|
||||
getConfig: (): InternalConfig => ({ ...currentConfig }),
|
||||
setConfig: (config: InternalConfig): InternalConfig => {
|
||||
setConfigCalled = true
|
||||
currentConfig = { ...currentConfig, ...config }
|
||||
return { ...currentConfig }
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
injectServerAuthIntoClient(client)
|
||||
if (!currentConfig.fetch) {
|
||||
throw new Error("expected fetch to exist")
|
||||
}
|
||||
await currentConfig.fetch(new Request("http://example.com"))
|
||||
|
||||
//#then
|
||||
expect(setConfigCalled).toBe(false)
|
||||
expect(receivedAuthorization).toBeNull()
|
||||
})
|
||||
|
||||
test("#given server password #when client has no _client #then does not throw", () => {
|
||||
process.env.OPENCODE_SERVER_PASSWORD = "secret"
|
||||
const client = {}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { log } from "./logger"
|
||||
|
||||
/**
|
||||
* Builds HTTP Basic Auth header from environment variables.
|
||||
*
|
||||
@@ -15,6 +17,132 @@ export function getServerBasicAuthHeader(): string | undefined {
|
||||
return `Basic ${token}`
|
||||
}
|
||||
|
||||
type UnknownRecord = Record<string, unknown>
|
||||
|
||||
function isRecord(value: unknown): value is UnknownRecord {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
function isRequestFetch(value: unknown): value is (request: Request) => Promise<Response> {
|
||||
return typeof value === "function"
|
||||
}
|
||||
|
||||
function wrapRequestFetch(
|
||||
baseFetch: (request: Request) => Promise<Response>,
|
||||
auth: string
|
||||
): (request: Request) => Promise<Response> {
|
||||
return async (request: Request): Promise<Response> => {
|
||||
const headers = new Headers(request.headers)
|
||||
headers.set("Authorization", auth)
|
||||
return baseFetch(new Request(request, { headers }))
|
||||
}
|
||||
}
|
||||
|
||||
function getInternalClient(client: unknown): UnknownRecord | null {
|
||||
if (!isRecord(client)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const internal = client["_client"]
|
||||
return isRecord(internal) ? internal : null
|
||||
}
|
||||
|
||||
function tryInjectViaSetConfigHeaders(internal: UnknownRecord, auth: string): boolean {
|
||||
const setConfig = internal["setConfig"]
|
||||
if (typeof setConfig !== "function") {
|
||||
return false
|
||||
}
|
||||
|
||||
setConfig({
|
||||
headers: {
|
||||
Authorization: auth,
|
||||
},
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function tryInjectViaInterceptors(internal: UnknownRecord, auth: string): boolean {
|
||||
const interceptors = internal["interceptors"]
|
||||
if (!isRecord(interceptors)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const requestInterceptors = interceptors["request"]
|
||||
if (!isRecord(requestInterceptors)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const use = requestInterceptors["use"]
|
||||
if (typeof use !== "function") {
|
||||
return false
|
||||
}
|
||||
|
||||
use((request: Request): Request => {
|
||||
if (!request.headers.get("Authorization")) {
|
||||
request.headers.set("Authorization", auth)
|
||||
}
|
||||
return request
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function tryInjectViaFetchWrapper(internal: UnknownRecord, auth: string): boolean {
|
||||
const getConfig = internal["getConfig"]
|
||||
const setConfig = internal["setConfig"]
|
||||
if (typeof getConfig !== "function" || typeof setConfig !== "function") {
|
||||
return false
|
||||
}
|
||||
|
||||
const config = getConfig()
|
||||
if (!isRecord(config)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const fetchValue = config["fetch"]
|
||||
if (!isRequestFetch(fetchValue)) {
|
||||
return false
|
||||
}
|
||||
|
||||
setConfig({
|
||||
fetch: wrapRequestFetch(fetchValue, auth),
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function tryInjectViaMutableInternalConfig(internal: UnknownRecord, auth: string): boolean {
|
||||
const configValue = internal["_config"]
|
||||
if (!isRecord(configValue)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const fetchValue = configValue["fetch"]
|
||||
if (!isRequestFetch(fetchValue)) {
|
||||
return false
|
||||
}
|
||||
|
||||
configValue["fetch"] = wrapRequestFetch(fetchValue, auth)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function tryInjectViaTopLevelFetch(client: unknown, auth: string): boolean {
|
||||
if (!isRecord(client)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const fetchValue = client["fetch"]
|
||||
if (!isRequestFetch(fetchValue)) {
|
||||
return false
|
||||
}
|
||||
|
||||
client["fetch"] = wrapRequestFetch(fetchValue, auth)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects HTTP Basic Auth header into the OpenCode SDK client.
|
||||
*
|
||||
@@ -34,36 +162,29 @@ export function injectServerAuthIntoClient(client: unknown): void {
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
typeof client !== "object" ||
|
||||
client === null ||
|
||||
!("_client" in client) ||
|
||||
typeof (client as { _client: unknown })._client !== "object" ||
|
||||
(client as { _client: unknown })._client === null
|
||||
) {
|
||||
throw new Error(
|
||||
"[opencode-server-auth] OPENCODE_SERVER_PASSWORD is set but SDK client structure is incompatible. " +
|
||||
"This may indicate an OpenCode SDK version mismatch."
|
||||
)
|
||||
const internal = getInternalClient(client)
|
||||
if (internal) {
|
||||
const injectedHeaders = tryInjectViaSetConfigHeaders(internal, auth)
|
||||
const injectedInterceptors = tryInjectViaInterceptors(internal, auth)
|
||||
const injectedFetch = tryInjectViaFetchWrapper(internal, auth)
|
||||
const injectedMutable = tryInjectViaMutableInternalConfig(internal, auth)
|
||||
|
||||
const injected = injectedHeaders || injectedInterceptors || injectedFetch || injectedMutable
|
||||
|
||||
if (!injected) {
|
||||
log("[opencode-server-auth] OPENCODE_SERVER_PASSWORD is set but SDK client structure is incompatible", {
|
||||
keys: Object.keys(internal),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const internal = (client as { _client: { setConfig?: (config: { headers: Record<string, string> }) => void } })
|
||||
._client
|
||||
|
||||
if (typeof internal.setConfig !== "function") {
|
||||
throw new Error(
|
||||
"[opencode-server-auth] OPENCODE_SERVER_PASSWORD is set but SDK client._client.setConfig is not a function. " +
|
||||
"This may indicate an OpenCode SDK version mismatch."
|
||||
)
|
||||
const injected = tryInjectViaTopLevelFetch(client, auth)
|
||||
if (!injected) {
|
||||
log("[opencode-server-auth] OPENCODE_SERVER_PASSWORD is set but no compatible SDK client found")
|
||||
}
|
||||
|
||||
internal.setConfig({
|
||||
headers: {
|
||||
Authorization: auth,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.warn(`[opencode-server-auth] Failed to inject server auth: ${message}`)
|
||||
log("[opencode-server-auth] Failed to inject server auth", { message })
|
||||
}
|
||||
}
|
||||
|
||||
50
src/tools/call-omo-agent/session-creator.test.ts
Normal file
50
src/tools/call-omo-agent/session-creator.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { createOrGetSession } from "./session-creator"
|
||||
import { _resetForTesting, subagentSessions } from "../../features/claude-code-session-state"
|
||||
|
||||
describe("call-omo-agent createOrGetSession", () => {
|
||||
test("creates child session without overriding permission and tracks it as subagent session", async () => {
|
||||
// given
|
||||
_resetForTesting()
|
||||
|
||||
const createCalls: Array<unknown> = []
|
||||
const ctx = {
|
||||
directory: "/project",
|
||||
client: {
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/parent" } }),
|
||||
create: async (args: unknown) => {
|
||||
createCalls.push(args)
|
||||
return { data: { id: "ses_child" } }
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const toolContext = {
|
||||
sessionID: "ses_parent",
|
||||
messageID: "msg_parent",
|
||||
agent: "sisyphus",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
const args = {
|
||||
description: "test",
|
||||
prompt: "hello",
|
||||
subagent_type: "explore",
|
||||
run_in_background: true,
|
||||
}
|
||||
|
||||
// when
|
||||
const result = await createOrGetSession(args as any, toolContext as any, ctx as any)
|
||||
|
||||
// then
|
||||
expect(result).toEqual({ sessionID: "ses_child", isNew: true })
|
||||
expect(createCalls).toHaveLength(1)
|
||||
const createBody = (createCalls[0] as any)?.body
|
||||
expect(createBody?.parentID).toBe("ses_parent")
|
||||
expect(createBody?.permission).toBeUndefined()
|
||||
expect(subagentSessions.has("ses_child")).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { CallOmoAgentArgs } from "./types"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { subagentSessions } from "../../features/claude-code-session-state"
|
||||
import { log } from "../../shared"
|
||||
|
||||
export async function createOrGetSession(
|
||||
@@ -38,9 +39,6 @@ export async function createOrGetSession(
|
||||
body: {
|
||||
parentID: toolContext.sessionID,
|
||||
title: `${args.description} (@${args.subagent_type} subagent)`,
|
||||
permission: [
|
||||
{ permission: "question", action: "deny" as const, pattern: "*" },
|
||||
],
|
||||
} as any,
|
||||
query: {
|
||||
directory: parentDirectory,
|
||||
@@ -65,6 +63,7 @@ Original error: ${createResult.error}`)
|
||||
|
||||
const sessionID = createResult.data.id
|
||||
log(`[call_omo_agent] Created session: ${sessionID}`)
|
||||
subagentSessions.add(sessionID)
|
||||
return { sessionID, isNew: true }
|
||||
}
|
||||
}
|
||||
|
||||
47
src/tools/call-omo-agent/subagent-session-creator.test.ts
Normal file
47
src/tools/call-omo-agent/subagent-session-creator.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { resolveOrCreateSessionId } from "./subagent-session-creator"
|
||||
import { _resetForTesting, subagentSessions } from "../../features/claude-code-session-state"
|
||||
|
||||
describe("call-omo-agent resolveOrCreateSessionId", () => {
|
||||
test("tracks newly created child session as subagent session", async () => {
|
||||
// given
|
||||
_resetForTesting()
|
||||
|
||||
const createCalls: Array<unknown> = []
|
||||
const ctx = {
|
||||
directory: "/project",
|
||||
client: {
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/parent" } }),
|
||||
create: async (args: unknown) => {
|
||||
createCalls.push(args)
|
||||
return { data: { id: "ses_child_sync" } }
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const args = {
|
||||
description: "sync test",
|
||||
prompt: "hello",
|
||||
subagent_type: "explore",
|
||||
run_in_background: false,
|
||||
}
|
||||
|
||||
const toolContext = {
|
||||
sessionID: "ses_parent",
|
||||
messageID: "msg_parent",
|
||||
agent: "sisyphus",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
// when
|
||||
const result = await resolveOrCreateSessionId(ctx as any, args as any, toolContext as any)
|
||||
|
||||
// then
|
||||
expect(result).toEqual({ ok: true, sessionID: "ses_child_sync" })
|
||||
expect(createCalls).toHaveLength(1)
|
||||
expect(subagentSessions.has("ses_child_sync")).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { log } from "../../shared"
|
||||
import { subagentSessions } from "../../features/claude-code-session-state"
|
||||
import type { CallOmoAgentArgs } from "./types"
|
||||
import type { ToolContextWithMetadata } from "./tool-context-with-metadata"
|
||||
|
||||
@@ -63,5 +64,6 @@ Original error: ${createResult.error}`,
|
||||
|
||||
const sessionID = createResult.data.id
|
||||
log(`[call_omo_agent] Created session: ${sessionID}`)
|
||||
subagentSessions.add(sessionID)
|
||||
return { ok: true, sessionID }
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export async function promptSubagentSession(
|
||||
tools: {
|
||||
...getAgentToolRestrictions(options.agent),
|
||||
task: false,
|
||||
question: false,
|
||||
},
|
||||
parts: [{ type: "text", text: options.prompt }],
|
||||
},
|
||||
|
||||
95
src/tools/delegate-task/background-continuation.test.ts
Normal file
95
src/tools/delegate-task/background-continuation.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
const { describe, test, expect, mock } = require("bun:test")
|
||||
|
||||
describe("executeBackgroundContinuation - subagent metadata", () => {
|
||||
test("includes subagent in task_metadata when task has agent", async () => {
|
||||
//#given - mock manager.resume returning task with agent info
|
||||
const mockManager = {
|
||||
resume: async () => ({
|
||||
id: "bg_task_001",
|
||||
description: "oracle consultation",
|
||||
agent: "oracle",
|
||||
status: "running",
|
||||
sessionID: "ses_resumed_123",
|
||||
}),
|
||||
}
|
||||
|
||||
const mockCtx = {
|
||||
sessionID: "parent-session",
|
||||
callID: "call-456",
|
||||
metadata: mock(() => Promise.resolve()),
|
||||
}
|
||||
|
||||
const mockExecutorCtx = {
|
||||
manager: mockManager,
|
||||
}
|
||||
|
||||
const parentContext = {
|
||||
sessionID: "parent-session",
|
||||
messageID: "msg-parent",
|
||||
agent: "sisyphus",
|
||||
}
|
||||
|
||||
const args = {
|
||||
session_id: "ses_resumed_123",
|
||||
prompt: "continue working",
|
||||
description: "resume oracle",
|
||||
load_skills: [],
|
||||
run_in_background: true,
|
||||
}
|
||||
|
||||
//#when - executeBackgroundContinuation completes
|
||||
const { executeBackgroundContinuation } = require("./background-continuation")
|
||||
const result = await executeBackgroundContinuation(args, mockCtx, mockExecutorCtx, parentContext)
|
||||
|
||||
//#then - task_metadata should contain subagent field
|
||||
expect(result).toContain("<task_metadata>")
|
||||
expect(result).toContain("subagent: oracle")
|
||||
expect(result).toContain("session_id: ses_resumed_123")
|
||||
})
|
||||
|
||||
test("omits subagent from task_metadata when task agent is undefined", async () => {
|
||||
//#given - mock manager.resume returning task without agent
|
||||
const mockManager = {
|
||||
resume: async () => ({
|
||||
id: "bg_task_002",
|
||||
description: "unknown task",
|
||||
agent: undefined,
|
||||
status: "running",
|
||||
sessionID: "ses_resumed_456",
|
||||
}),
|
||||
}
|
||||
|
||||
const mockCtx = {
|
||||
sessionID: "parent-session",
|
||||
callID: "call-789",
|
||||
metadata: mock(() => Promise.resolve()),
|
||||
}
|
||||
|
||||
const mockExecutorCtx = {
|
||||
manager: mockManager,
|
||||
}
|
||||
|
||||
const parentContext = {
|
||||
sessionID: "parent-session",
|
||||
messageID: "msg-parent",
|
||||
agent: "sisyphus",
|
||||
}
|
||||
|
||||
const args = {
|
||||
session_id: "ses_resumed_456",
|
||||
prompt: "continue",
|
||||
description: "resume task",
|
||||
load_skills: [],
|
||||
run_in_background: true,
|
||||
}
|
||||
|
||||
//#when - executeBackgroundContinuation completes without agent
|
||||
const { executeBackgroundContinuation } = require("./background-continuation")
|
||||
const result = await executeBackgroundContinuation(args, mockCtx, mockExecutorCtx, parentContext)
|
||||
|
||||
//#then - task_metadata should NOT contain subagent field
|
||||
expect(result).toContain("<task_metadata>")
|
||||
expect(result).toContain("session_id: ses_resumed_456")
|
||||
expect(result).not.toContain("subagent:")
|
||||
})
|
||||
})
|
||||
@@ -50,7 +50,7 @@ Use \`background_output\` with task_id="${task.id}" to check progress.
|
||||
|
||||
<task_metadata>
|
||||
session_id: ${task.sessionID}
|
||||
</task_metadata>`
|
||||
${task.agent ? `subagent: ${task.agent}\n` : ""}</task_metadata>`
|
||||
} catch (error) {
|
||||
return formatDetailedError(error, {
|
||||
operation: "Continue background task",
|
||||
|
||||
@@ -32,7 +32,10 @@ export function resolveCategoryConfig(
|
||||
const userConfig = userCategories?.[categoryName]
|
||||
const hasExplicitUserConfig = userConfig !== undefined
|
||||
|
||||
// Check if category requires a specific model - bypass if user explicitly provides config
|
||||
if (userConfig?.disable) {
|
||||
return null
|
||||
}
|
||||
|
||||
const categoryReq = CATEGORY_MODEL_REQUIREMENTS[categoryName]
|
||||
if (categoryReq?.requiresModel && availableModels && !hasExplicitUserConfig) {
|
||||
if (!isModelAvailable(categoryReq.requiresModel, availableModels)) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ModelFallbackInfo } from "../../features/task-toast-manager/types"
|
||||
import type { DelegateTaskArgs } from "./types"
|
||||
import type { ExecutorContext } from "./executor-types"
|
||||
import { DEFAULT_CATEGORIES } from "./constants"
|
||||
import { mergeCategories } from "../../shared/merge-categories"
|
||||
import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent"
|
||||
import { resolveCategoryConfig } from "./categories"
|
||||
import { parseModelString } from "./model-string-parser"
|
||||
@@ -30,7 +30,8 @@ export async function resolveCategoryExecution(
|
||||
const availableModels = await getAvailableModelsForDelegateTask(client)
|
||||
|
||||
const categoryName = args.category!
|
||||
const categoryExists = DEFAULT_CATEGORIES[categoryName] !== undefined || userCategories?.[categoryName] !== undefined
|
||||
const enabledCategories = mergeCategories(userCategories)
|
||||
const categoryExists = enabledCategories[categoryName] !== undefined
|
||||
|
||||
const resolved = resolveCategoryConfig(categoryName, {
|
||||
userCategories,
|
||||
@@ -41,7 +42,7 @@ export async function resolveCategoryExecution(
|
||||
|
||||
if (!resolved) {
|
||||
const requirement = CATEGORY_MODEL_REQUIREMENTS[categoryName]
|
||||
const allCategoryNames = Object.keys({ ...DEFAULT_CATEGORIES, ...userCategories }).join(", ")
|
||||
const allCategoryNames = Object.keys(enabledCategories).join(", ")
|
||||
|
||||
if (categoryExists && requirement?.requiresModel) {
|
||||
return {
|
||||
@@ -146,7 +147,7 @@ Available categories: ${allCategoryNames}`,
|
||||
const categoryPromptAppend = resolved.promptAppend || undefined
|
||||
|
||||
if (!categoryModel && !actualModel) {
|
||||
const categoryNames = Object.keys({ ...DEFAULT_CATEGORIES, ...userCategories })
|
||||
const categoryNames = Object.keys(enabledCategories)
|
||||
return {
|
||||
agentToUse: "",
|
||||
categoryModel: undefined,
|
||||
|
||||
@@ -356,4 +356,112 @@ describe("executeSyncContinuation - toast cleanup error paths", () => {
|
||||
expect(addTaskCalls.length).toBe(0)
|
||||
expect(removeTaskCalls.length).toBe(0)
|
||||
})
|
||||
|
||||
test("includes subagent in task_metadata when agent info is present in session messages", async () => {
|
||||
//#given - mock session messages with agent info on the last assistant message
|
||||
const mockClient = {
|
||||
session: {
|
||||
messages: async () => ({
|
||||
data: [
|
||||
{ info: { id: "msg_001", role: "user", time: { created: 1000 }, agent: "oracle" } },
|
||||
{
|
||||
info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "end_turn", agent: "oracle", providerID: "openai", modelID: "gpt-5.2" },
|
||||
parts: [{ type: "text", text: "Response" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
promptAsync: async () => ({}),
|
||||
status: async () => ({
|
||||
data: { ses_test: { type: "idle" } },
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
const { executeSyncContinuation } = require("./sync-continuation")
|
||||
|
||||
const deps = {
|
||||
pollSyncSession: async () => null,
|
||||
fetchSyncResult: async () => ({ ok: true as const, textContent: "Result" }),
|
||||
}
|
||||
|
||||
const mockCtx = {
|
||||
sessionID: "parent-session",
|
||||
callID: "call-123",
|
||||
metadata: () => {},
|
||||
}
|
||||
|
||||
const mockExecutorCtx = {
|
||||
client: mockClient,
|
||||
}
|
||||
|
||||
const args = {
|
||||
session_id: "ses_test_12345678",
|
||||
prompt: "continue working",
|
||||
description: "resume oracle task",
|
||||
load_skills: [],
|
||||
run_in_background: false,
|
||||
}
|
||||
|
||||
//#when - executeSyncContinuation completes with agent info in messages
|
||||
const result = await executeSyncContinuation(args, mockCtx, mockExecutorCtx, deps)
|
||||
|
||||
//#then - task_metadata should contain subagent field with the agent name
|
||||
expect(result).toContain("<task_metadata>")
|
||||
expect(result).toContain("subagent: oracle")
|
||||
expect(result).toContain("session_id: ses_test_12345678")
|
||||
})
|
||||
|
||||
test("omits subagent from task_metadata when no agent info in session messages", async () => {
|
||||
//#given - mock session messages without any agent info
|
||||
const mockClient = {
|
||||
session: {
|
||||
messages: async () => ({
|
||||
data: [
|
||||
{ info: { id: "msg_001", role: "user", time: { created: 1000 } } },
|
||||
{
|
||||
info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "end_turn" },
|
||||
parts: [{ type: "text", text: "Response" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
promptAsync: async () => ({}),
|
||||
status: async () => ({
|
||||
data: { ses_test: { type: "idle" } },
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
const { executeSyncContinuation } = require("./sync-continuation")
|
||||
|
||||
const deps = {
|
||||
pollSyncSession: async () => null,
|
||||
fetchSyncResult: async () => ({ ok: true as const, textContent: "Result" }),
|
||||
}
|
||||
|
||||
const mockCtx = {
|
||||
sessionID: "parent-session",
|
||||
callID: "call-123",
|
||||
metadata: () => {},
|
||||
}
|
||||
|
||||
const mockExecutorCtx = {
|
||||
client: mockClient,
|
||||
}
|
||||
|
||||
const args = {
|
||||
session_id: "ses_test_12345678",
|
||||
prompt: "continue working",
|
||||
description: "resume task",
|
||||
load_skills: [],
|
||||
run_in_background: false,
|
||||
}
|
||||
|
||||
//#when - executeSyncContinuation completes without agent info
|
||||
const result = await executeSyncContinuation(args, mockCtx, mockExecutorCtx, deps)
|
||||
|
||||
//#then - task_metadata should NOT contain subagent field
|
||||
expect(result).toContain("<task_metadata>")
|
||||
expect(result).toContain("session_id: ses_test_12345678")
|
||||
expect(result).not.toContain("subagent:")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -128,7 +128,7 @@ ${result.textContent || "(No text output)"}
|
||||
|
||||
<task_metadata>
|
||||
session_id: ${args.session_id}
|
||||
</task_metadata>`
|
||||
${resumeAgent ? `subagent: ${resumeAgent}\n` : ""}</task_metadata>`
|
||||
} finally {
|
||||
if (toastManager) {
|
||||
toastManager.removeTask(taskId)
|
||||
|
||||
@@ -13,9 +13,6 @@ export async function createSyncSession(
|
||||
body: {
|
||||
parentID: input.parentSessionID,
|
||||
title: `${input.description} (@${input.agentToUse} subagent)`,
|
||||
permission: [
|
||||
{ permission: "question", action: "deny" as const, pattern: "*" },
|
||||
],
|
||||
} as any,
|
||||
query: {
|
||||
directory: parentDirectory,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import type { DelegateTaskArgs, ToolContextWithMetadata, DelegateTaskToolOptions } from "./types"
|
||||
import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "./constants"
|
||||
import { CATEGORY_DESCRIPTIONS } from "./constants"
|
||||
import { mergeCategories } from "../../shared/merge-categories"
|
||||
import { log } from "../../shared/logger"
|
||||
import { buildSystemContent } from "./prompt-builder"
|
||||
import type {
|
||||
@@ -26,7 +27,7 @@ export { buildSystemContent } from "./prompt-builder"
|
||||
export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefinition {
|
||||
const { userCategories } = options
|
||||
|
||||
const allCategories = { ...DEFAULT_CATEGORIES, ...userCategories }
|
||||
const allCategories = mergeCategories(userCategories)
|
||||
const categoryNames = Object.keys(allCategories)
|
||||
const categoryExamples = categoryNames.map(k => `'${k}'`).join(", ")
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { describe, expect, test, mock } from "bun:test"
|
||||
import type { ToolContext } from "@opencode-ai/plugin/tool"
|
||||
import { normalizeArgs, validateArgs, createLookAt } from "./tools"
|
||||
|
||||
@@ -111,16 +111,15 @@ describe("look-at tool", () => {
|
||||
})
|
||||
|
||||
describe("createLookAt error handling", () => {
|
||||
// given promptAsync throws error
|
||||
// given sync prompt throws and no messages available
|
||||
// when LookAt tool executed
|
||||
// then returns error string immediately (no message fetch)
|
||||
test("returns error immediately when promptAsync fails", async () => {
|
||||
// then returns no-response error (fetches messages after catching prompt error)
|
||||
test("returns no-response error when prompt fails and no messages exist", async () => {
|
||||
const mockClient = {
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "ses_test_prompt_fail" } }),
|
||||
promptAsync: async () => { throw new Error("Network connection failed") },
|
||||
status: async () => ({ data: {} }),
|
||||
prompt: async () => { throw new Error("Network connection failed") },
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
@@ -146,51 +145,10 @@ describe("look-at tool", () => {
|
||||
toolContext,
|
||||
)
|
||||
expect(result).toContain("Error")
|
||||
expect(result).toContain("Network connection failed")
|
||||
expect(result).toContain("multimodal-looker")
|
||||
})
|
||||
|
||||
// given promptAsync succeeds but status API fails (polling degrades gracefully)
|
||||
// when LookAt tool executed
|
||||
// then still attempts to fetch messages (graceful degradation)
|
||||
test("fetches messages even when status API fails", async () => {
|
||||
const mockClient = {
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "ses_test_poll_timeout" } }),
|
||||
promptAsync: async () => ({}),
|
||||
status: async () => ({ error: new Error("status unavailable") }),
|
||||
messages: async () => ({
|
||||
data: [
|
||||
{ info: { role: "assistant", time: { created: 1 } }, parts: [{ type: "text", text: "partial result" }] },
|
||||
],
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
const tool = createLookAt({
|
||||
client: mockClient,
|
||||
directory: "/project",
|
||||
} as any)
|
||||
|
||||
const toolContext: ToolContext = {
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
agent: "sisyphus",
|
||||
directory: "/project",
|
||||
worktree: "/project",
|
||||
abort: new AbortController().signal,
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
const result = await tool.execute(
|
||||
{ file_path: "/test/file.png", goal: "analyze" },
|
||||
toolContext,
|
||||
)
|
||||
expect(result).toBe("partial result")
|
||||
})
|
||||
|
||||
// given promptAsync succeeds and session becomes idle
|
||||
// given sync prompt succeeds
|
||||
// when LookAt tool executed and no assistant message found
|
||||
// then returns error about no response
|
||||
test("returns error when no assistant message after successful prompt", async () => {
|
||||
@@ -198,8 +156,7 @@ describe("look-at tool", () => {
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "ses_test_no_msg" } }),
|
||||
promptAsync: async () => ({}),
|
||||
status: async () => ({ data: {} }),
|
||||
prompt: async () => ({}),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
@@ -236,8 +193,7 @@ describe("look-at tool", () => {
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ error: "Internal server error" }),
|
||||
promptAsync: async () => ({}),
|
||||
status: async () => ({ data: {} }),
|
||||
prompt: async () => ({}),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
@@ -270,8 +226,8 @@ describe("look-at tool", () => {
|
||||
describe("createLookAt model passthrough", () => {
|
||||
// given multimodal-looker agent has resolved model info
|
||||
// when LookAt tool executed
|
||||
// then model info should be passed to promptAsync
|
||||
test("passes multimodal-looker model to promptAsync when available", async () => {
|
||||
// then model info should be passed to sync prompt
|
||||
test("passes multimodal-looker model to sync prompt when available", async () => {
|
||||
let promptBody: any
|
||||
|
||||
const mockClient = {
|
||||
@@ -289,11 +245,10 @@ describe("look-at tool", () => {
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "ses_model_passthrough" } }),
|
||||
promptAsync: async (input: any) => {
|
||||
prompt: async (input: any) => {
|
||||
promptBody = input.body
|
||||
return { data: {} }
|
||||
},
|
||||
status: async () => ({ data: {} }),
|
||||
messages: async () => ({
|
||||
data: [
|
||||
{ info: { role: "assistant", time: { created: 1 } }, parts: [{ type: "text", text: "done" }] },
|
||||
@@ -330,10 +285,154 @@ describe("look-at tool", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("createLookAt sync prompt (race condition fix)", () => {
|
||||
// given look_at needs response immediately after prompt returns
|
||||
// when tool is executed
|
||||
// then must use synchronous prompt (session.prompt), NOT async (session.promptAsync)
|
||||
test("uses synchronous prompt to avoid race condition with polling", async () => {
|
||||
const syncPrompt = mock(async () => ({}))
|
||||
const asyncPrompt = mock(async () => ({}))
|
||||
const statusFn = mock(async () => ({ data: {} }))
|
||||
|
||||
const mockClient = {
|
||||
app: {
|
||||
agents: async () => ({ data: [] }),
|
||||
},
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "ses_sync_test" } }),
|
||||
prompt: syncPrompt,
|
||||
promptAsync: asyncPrompt,
|
||||
status: statusFn,
|
||||
messages: async () => ({
|
||||
data: [
|
||||
{ info: { role: "assistant", time: { created: 1 } }, parts: [{ type: "text", text: "result" }] },
|
||||
],
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
const tool = createLookAt({
|
||||
client: mockClient,
|
||||
directory: "/project",
|
||||
} as any)
|
||||
|
||||
const toolContext: ToolContext = {
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
agent: "sisyphus",
|
||||
directory: "/project",
|
||||
worktree: "/project",
|
||||
abort: new AbortController().signal,
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
const result = await tool.execute(
|
||||
{ file_path: "/test/file.png", goal: "analyze" },
|
||||
toolContext,
|
||||
)
|
||||
|
||||
expect(result).toBe("result")
|
||||
expect(syncPrompt).toHaveBeenCalledTimes(1)
|
||||
expect(asyncPrompt).not.toHaveBeenCalled()
|
||||
expect(statusFn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// given sync prompt throws (JSON parse error even on success)
|
||||
// when tool is executed
|
||||
// then catches error gracefully and still fetches messages
|
||||
test("catches sync prompt errors and still fetches messages", async () => {
|
||||
const mockClient = {
|
||||
app: {
|
||||
agents: async () => ({ data: [] }),
|
||||
},
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "ses_sync_error" } }),
|
||||
prompt: async () => { throw new Error("JSON parse error") },
|
||||
promptAsync: async () => ({}),
|
||||
status: async () => ({ data: {} }),
|
||||
messages: async () => ({
|
||||
data: [
|
||||
{ info: { role: "assistant", time: { created: 1 } }, parts: [{ type: "text", text: "result despite error" }] },
|
||||
],
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
const tool = createLookAt({
|
||||
client: mockClient,
|
||||
directory: "/project",
|
||||
} as any)
|
||||
|
||||
const toolContext: ToolContext = {
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
agent: "sisyphus",
|
||||
directory: "/project",
|
||||
worktree: "/project",
|
||||
abort: new AbortController().signal,
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
const result = await tool.execute(
|
||||
{ file_path: "/test/file.png", goal: "analyze" },
|
||||
toolContext,
|
||||
)
|
||||
|
||||
expect(result).toBe("result despite error")
|
||||
})
|
||||
|
||||
// given sync prompt throws and no messages available
|
||||
// when tool is executed
|
||||
// then returns error about no response
|
||||
test("returns no-response error when sync prompt fails and no messages", async () => {
|
||||
const mockClient = {
|
||||
app: {
|
||||
agents: async () => ({ data: [] }),
|
||||
},
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "ses_sync_no_msg" } }),
|
||||
prompt: async () => { throw new Error("Connection refused") },
|
||||
promptAsync: async () => ({}),
|
||||
status: async () => ({ data: {} }),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
|
||||
const tool = createLookAt({
|
||||
client: mockClient,
|
||||
directory: "/project",
|
||||
} as any)
|
||||
|
||||
const toolContext: ToolContext = {
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
agent: "sisyphus",
|
||||
directory: "/project",
|
||||
worktree: "/project",
|
||||
abort: new AbortController().signal,
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
const result = await tool.execute(
|
||||
{ file_path: "/test/file.png", goal: "analyze" },
|
||||
toolContext,
|
||||
)
|
||||
|
||||
expect(result).toContain("Error")
|
||||
expect(result).toContain("multimodal-looker")
|
||||
})
|
||||
})
|
||||
|
||||
describe("createLookAt with image_data", () => {
|
||||
// given base64 image data is provided
|
||||
// when LookAt tool executed
|
||||
// then should send data URL to promptAsync
|
||||
// then should send data URL to sync prompt
|
||||
test("sends data URL when image_data provided", async () => {
|
||||
let promptBody: any
|
||||
|
||||
@@ -344,11 +443,10 @@ describe("look-at tool", () => {
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "ses_image_data_test" } }),
|
||||
promptAsync: async (input: any) => {
|
||||
prompt: async (input: any) => {
|
||||
promptBody = input.body
|
||||
return { data: {} }
|
||||
},
|
||||
status: async () => ({ data: {} }),
|
||||
messages: async () => ({
|
||||
data: [
|
||||
{ info: { role: "assistant", time: { created: 1 } }, parts: [{ type: "text", text: "analyzed" }] },
|
||||
@@ -398,11 +496,10 @@ describe("look-at tool", () => {
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "ses_raw_base64_test" } }),
|
||||
promptAsync: async (input: any) => {
|
||||
prompt: async (input: any) => {
|
||||
promptBody = input.body
|
||||
return { data: {} }
|
||||
},
|
||||
status: async () => ({ data: {} }),
|
||||
messages: async () => ({
|
||||
data: [
|
||||
{ info: { role: "assistant", time: { created: 1 } }, parts: [{ type: "text", text: "analyzed" }] },
|
||||
|
||||
@@ -3,8 +3,7 @@ import { pathToFileURL } from "node:url"
|
||||
import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import { LOOK_AT_DESCRIPTION, MULTIMODAL_LOOKER_AGENT } from "./constants"
|
||||
import type { LookAtArgs } from "./types"
|
||||
import { log, promptWithModelSuggestionRetry } from "../../shared"
|
||||
import { pollSessionUntilIdle } from "./session-poller"
|
||||
import { log, promptSyncWithModelSuggestionRetry } from "../../shared"
|
||||
import { extractLatestAssistantText } from "./assistant-message-extractor"
|
||||
import type { LookAtArgsWithAlias } from "./look-at-arguments"
|
||||
import { normalizeArgs, validateArgs } from "./look-at-arguments"
|
||||
@@ -106,9 +105,9 @@ Original error: ${createResult.error}`
|
||||
|
||||
const { agentModel, agentVariant } = await resolveMultimodalLookerAgentMetadata(ctx)
|
||||
|
||||
log(`[look_at] Sending async prompt with ${isBase64Input ? "base64 image" : "file"} to session ${sessionID}`)
|
||||
log(`[look_at] Sending prompt with ${isBase64Input ? "base64 image" : "file"} to session ${sessionID}`)
|
||||
try {
|
||||
await promptWithModelSuggestionRetry(ctx.client, {
|
||||
await promptSyncWithModelSuggestionRetry(ctx.client, {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: MULTIMODAL_LOOKER_AGENT,
|
||||
@@ -127,15 +126,7 @@ Original error: ${createResult.error}`
|
||||
},
|
||||
})
|
||||
} catch (promptError) {
|
||||
log(`[look_at] promptAsync error:`, promptError)
|
||||
return `Error: Failed to send prompt to multimodal-looker agent: ${promptError instanceof Error ? promptError.message : String(promptError)}`
|
||||
}
|
||||
|
||||
log(`[look_at] Polling session ${sessionID} until idle...`)
|
||||
try {
|
||||
await pollSessionUntilIdle(ctx.client, sessionID, { pollIntervalMs: 500, timeoutMs: 120_000 })
|
||||
} catch (pollError) {
|
||||
log(`[look_at] Polling error (will still try to fetch messages):`, pollError)
|
||||
log(`[look_at] Prompt error (ignored, will still fetch messages):`, promptError)
|
||||
}
|
||||
|
||||
log(`[look_at] Fetching messages from session ${sessionID}...`)
|
||||
|
||||
@@ -88,7 +88,9 @@ export function createSlashcommandTool(options: SlashcommandToolOptions = {}): T
|
||||
return `No exact match for "/${commandName}". Did you mean: ${matchList}?\n\n${formatCommandList(allItems)}`
|
||||
}
|
||||
|
||||
return `Command or skill "/${commandName}" not found.\n\n${formatCommandList(allItems)}\n\nTry a different name.`
|
||||
return commandName.includes(":")
|
||||
? `Marketplace plugin commands like "/${commandName}" are not supported. Use .claude/commands/ for custom commands.\n\n${formatCommandList(allItems)}`
|
||||
: `Command or skill "/${commandName}" not found.\n\n${formatCommandList(allItems)}\n\nTry a different name.`
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user