Compare commits

..

1 Commits

Author SHA1 Message Date
YeonGyu-Kim
8b44d3b7b5 fix: add null guards for output.output in tool.execute.after hooks
Three hooks crashed with TypeError when MCP tools returned results where
output.output is undefined. Added type guards to all affected hooks:

- comment-checker/hook.ts: guard before toLowerCase()
- edit-error-recovery/hook.ts: guard before toLowerCase()
- task-resume-info/hook.ts: guard before startsWith()/includes()/trimEnd()
- Added test for undefined output.output in edit-error-recovery

Fixes #1746
2026-02-11 15:39:15 +09:00
46 changed files with 171 additions and 751 deletions

View File

@@ -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.1",
"oh-my-opencode-darwin-x64": "3.5.1",
"oh-my-opencode-linux-arm64": "3.5.1",
"oh-my-opencode-linux-arm64-musl": "3.5.1",
"oh-my-opencode-linux-x64": "3.5.1",
"oh-my-opencode-linux-x64-musl": "3.5.1",
"oh-my-opencode-windows-x64": "3.5.1",
},
},
},
@@ -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.1", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-oH+c/+Z/ULIK+8T1jQFpzISHsvQPyYJfA6bceiD9sgFy1OY1NjRh4a3sFk8cXy6uRVKpivWDFOfbVTcZ2kbKWA=="],
"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.1", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-wnBYQ9BZBLbzgSNIJZOIJS03zf+b4trAQeYmG+yCLn8y7FWXqw1KmjJ88/bbMXTuZ4RSMKWpXb1Afgdsred+DQ=="],
"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.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-19KNJex1LeU/S14IsJbumOvZa9O6F7X4BLIY7MfjtHtTk0dRFL+tbbXmlafecBMigEKlLdJ+HTW3TnQgp7Ih8A=="],
"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.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-mCCnym3nBTJP+xzK+AS4YPFQiT2sZWmjhOhOy7PjNY6Is4jkfT1C2e9ZrIU/2VoVLV6V5q7hQGh1jgleU+FxwQ=="],
"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.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-sDYt4adNuwb+p1RzHb7IR9zvbAnYYgZofjPvceirBorffp63f+aypYFxjFpfmbT87o/Eb/Hgzm4sHliJtd1UmQ=="],
"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.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-tz/0QSS5AKIiKj6cMom5VQSnEYpMIP/SRTaP5WYNOYhnUkXMwXEncQ7FIcj2vovMCXuqA9a8ujVY0zTs7TeALw=="],
"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.1", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-zfpRS6HIkSwE8btajJzSYxhqsE5kDkop896/XGS3LLIAAZt0RtCmT3C1plxVfI9oAABfgcaiveCxJ5f9AlKPcQ=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode",
"version": "3.5.2",
"version": "3.5.1",
"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.2",
"oh-my-opencode-darwin-x64": "3.5.2",
"oh-my-opencode-linux-arm64": "3.5.2",
"oh-my-opencode-linux-arm64-musl": "3.5.2",
"oh-my-opencode-linux-x64": "3.5.2",
"oh-my-opencode-linux-x64-musl": "3.5.2",
"oh-my-opencode-windows-x64": "3.5.2"
"oh-my-opencode-darwin-arm64": "3.5.1",
"oh-my-opencode-darwin-x64": "3.5.1",
"oh-my-opencode-linux-arm64": "3.5.1",
"oh-my-opencode-linux-arm64-musl": "3.5.1",
"oh-my-opencode-linux-x64": "3.5.1",
"oh-my-opencode-linux-x64-musl": "3.5.1",
"oh-my-opencode-windows-x64": "3.5.1"
},
"trustedDependencies": [
"@ast-grep/cli",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1351,14 +1351,6 @@
"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
}
]
}

View File

@@ -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 { mergeCategories } from "../shared/merge-categories"
import { DEFAULT_CATEGORIES } from "../tools/delegate-task/constants"
import { resolveMultipleSkills } from "../features/opencode-skill-loader/skill-content"
export type AgentSource = AgentFactory | AgentConfig
@@ -20,7 +20,9 @@ export function buildAgent(
disabledSkills?: Set<string>
): AgentConfig {
const base = isFactory(source) ? source(model) : { ...source }
const categoryConfigs: Record<string, CategoryConfig> = mergeCategories(categories)
const categoryConfigs: Record<string, CategoryConfig> = categories
? { ...DEFAULT_CATEGORIES, ...categories }
: DEFAULT_CATEGORIES
const agentWithCategory = base as AgentConfig & { category?: string; skills?: string[]; variant?: string }
if (agentWithCategory.category) {

View File

@@ -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 { mergeCategories } from "../../shared/merge-categories"
import { DEFAULT_CATEGORIES } from "../../tools/delegate-task/constants"
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 = mergeCategories(userCategories)
const allCategories = { ...DEFAULT_CATEGORIES, ...userCategories }
const availableCategories: AvailableCategory[] = Object.entries(allCategories).map(([name]) => ({
name,
description: getCategoryDescription(name, userCategories),

View File

@@ -7,8 +7,7 @@
import type { CategoryConfig } from "../../config/schema"
import { formatCustomSkillsBlock, type AvailableAgent, type AvailableSkill } from "../dynamic-agent-prompt-builder"
import { CATEGORY_DESCRIPTIONS } from "../../tools/delegate-task/constants"
import { mergeCategories } from "../../shared/merge-categories"
import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "../../tools/delegate-task/constants"
import { truncateDescription } from "../../shared/truncate-description"
export const getCategoryDescription = (name: string, userCategories?: Record<string, CategoryConfig>) =>
@@ -34,7 +33,7 @@ ${rows.join("\n")}`
}
export function buildCategorySection(userCategories?: Record<string, CategoryConfig>): string {
const allCategories = mergeCategories(userCategories)
const allCategories = { ...DEFAULT_CATEGORIES, ...userCategories }
const categoryRows = Object.entries(allCategories).map(([name, config]) => {
const temp = config.temperature ?? 0.5
return `| \`${name}\` | ${temp} | ${getCategoryDescription(name, userCategories)} |`
@@ -117,7 +116,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 = mergeCategories(userCategories)
const allCategories = { ...DEFAULT_CATEGORIES, ...userCategories }
const categoryRows = Object.entries(allCategories).map(([name]) =>
`| ${getCategoryDescription(name, userCategories)} | \`category="${name}", load_skills=[...]\` |`

View File

@@ -14,8 +14,7 @@ import { createMomusAgent, momusPromptMetadata } from "./momus"
import { createHephaestusAgent } from "./hephaestus"
import type { AvailableCategory } from "./dynamic-agent-prompt-builder"
import { fetchAvailableModels, readConnectedProvidersCache } from "../shared"
import { CATEGORY_DESCRIPTIONS } from "../tools/delegate-task/constants"
import { mergeCategories } from "../shared/merge-categories"
import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "../tools/delegate-task/constants"
import { buildAvailableSkills } from "./builtin-agents/available-skills"
import { collectPendingBuiltinAgents } from "./builtin-agents/general-agents"
import { maybeCreateSisyphusConfig } from "./builtin-agents/sisyphus-agent"
@@ -79,7 +78,9 @@ export async function createBuiltinAgents(
const result: Record<string, AgentConfig> = {}
const mergedCategories = mergeCategories(categories)
const mergedCategories = categories
? { ...DEFAULT_CATEGORIES, ...categories }
: DEFAULT_CATEGORIES
const availableCategories: AvailableCategory[] = Object.entries(mergedCategories).map(([name]) => ({
name,

View File

@@ -20,8 +20,6 @@ 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([

View File

@@ -1520,7 +1520,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
})
describe("task transitions pending→running when slot available", () => {
test("does not override parent session permission when creating child session", async () => {
test("should inherit parent session permission rules (and force deny question)", async () => {
// given
const createCalls: any[] = []
const parentPermission = [
@@ -1562,7 +1562,11 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
// then
expect(createCalls).toHaveLength(1)
expect(createCalls[0]?.body?.permission).toBeUndefined()
const permission = createCalls[0]?.body?.permission
expect(permission).toEqual([
{ permission: "plan_enter", action: "deny", pattern: "*" },
{ permission: "question", action: "deny", pattern: "*" },
])
})
test("should transition first task to running immediately", async () => {

View File

@@ -236,10 +236,17 @@ 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,

View File

@@ -3,7 +3,7 @@ import { describe, test, expect } from "bun:test"
import { createTask, startTask } from "./spawner"
describe("background-agent spawner.startTask", () => {
test("does not override parent session permission rules when creating child session", async () => {
test("should inherit parent session permission rules (and force deny question)", async () => {
//#given
const createCalls: any[] = []
const parentPermission = [
@@ -57,6 +57,9 @@ describe("background-agent spawner.startTask", () => {
//#then
expect(createCalls).toHaveLength(1)
expect(createCalls[0]?.body?.permission).toBeUndefined()
expect(createCalls[0]?.body?.permission).toEqual([
{ permission: "plan_enter", action: "deny", pattern: "*" },
{ permission: "question", action: "deny", pattern: "*" },
])
})
})

View File

@@ -58,10 +58,17 @@ 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: {

View File

@@ -15,6 +15,7 @@ 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

View File

@@ -69,6 +69,7 @@ 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,

View File

@@ -26,23 +26,7 @@ export function buildOrchestratorReminder(
${buildVerificationReminder(sessionId)}
**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)**
**STEP 5: CHECK BOULDER STATE DIRECTLY (EVERY TIME — NO EXCEPTIONS)**
Do NOT rely on cached progress. Read the plan file NOW:
\`\`\`
@@ -51,7 +35,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 7: MARK COMPLETION IN PLAN FILE (IMMEDIATELY)**
**STEP 6: MARK COMPLETION IN PLAN FILE (IMMEDIATELY)**
RIGHT NOW - Do not delay. Verification passed → Mark IMMEDIATELY.
@@ -61,12 +45,12 @@ Update the plan file \`.sisyphus/plans/${planName}.md\`:
**DO THIS BEFORE ANYTHING ELSE. Unmarked = Untracked = Lost progress.**
**STEP 8: COMMIT ATOMIC UNIT**
**STEP 7: COMMIT ATOMIC UNIT**
- Stage ONLY the verified changes
- Commit with clear message describing what was done
**STEP 9: PROCEED TO NEXT TASK**
**STEP 8: PROCEED TO NEXT TASK**
- Read the plan file AGAIN to identify the next \`- [ ]\` task
- Start immediately - DO NOT STOP

View File

@@ -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, revertPinnedVersion } from "./checker/pinned-version-updater"
export { updatePinnedVersion } from "./checker/pinned-version-updater"
export { getLatestVersion } from "./checker/latest-version"
export { checkForUpdate } from "./checker/check-for-update"

View File

@@ -1,133 +0,0 @@
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")
})
})
})

View File

@@ -2,9 +2,10 @@ import * as fs from "node:fs"
import { log } from "../../../shared/logger"
import { PACKAGE_NAME } from "../constants"
function replacePluginEntry(configPath: string, oldEntry: string, newEntry: string): boolean {
export function updatePinnedVersion(configPath: string, oldEntry: string, newVersion: 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) {
@@ -50,13 +51,3 @@ function replacePluginEntry(configPath: string, oldEntry: string, newEntry: stri
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)
}

View File

@@ -1,174 +0,0 @@
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()
})
})
})

View File

@@ -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, revertPinnedVersion } from "../checker"
import { findPluginEntry, getCachedVersion, getLatestVersion, updatePinnedVersion } from "../checker"
import { showAutoUpdatedToast, showUpdateAvailableToast } from "./update-toasts"
async function runBunInstallSafe(): Promise<boolean> {
@@ -56,9 +56,13 @@ export async function runBackgroundUpdateCheck(
}
if (pluginInfo.isPinned) {
await showUpdateAvailableToast(ctx, latestVersion, getToastMessage)
log(`[auto-update-checker] User-pinned version detected (${pluginInfo.entry}), skipping auto-update. Notification only.`)
return
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}`)
}
invalidatePackage(PACKAGE_NAME)
@@ -68,14 +72,8 @@ export async function runBackgroundUpdateCheck(
if (installSuccess) {
await showAutoUpdatedToast(ctx, currentVersion, latestVersion)
log(`[auto-update-checker] Update installed: ${currentVersion}${latestVersion}`)
return
} else {
await showUpdateAvailableToast(ctx, latestVersion, getToastMessage)
log("[auto-update-checker] bun install failed; update not installed (falling back to notification-only)")
}
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)")
}

View File

@@ -0,0 +1,24 @@
import { describe, it, expect, mock } from "bun:test"
mock.module("./cli-runner", () => ({
initializeCommentCheckerCli: () => {},
getCommentCheckerCliPathPromise: () => Promise.resolve("/tmp/fake-comment-checker"),
isCliPathUsable: () => true,
processWithCli: async () => {},
processApplyPatchEditsWithCli: async () => {},
}))
const { createCommentCheckerHooks } = await import("./hook")
describe("comment-checker output guard", () => {
//#given output.output is undefined
//#when tool.execute.after is called
//#then should return without throwing
it("should not throw when output.output is undefined", async () => {
const hooks = createCommentCheckerHooks()
const input = { tool: "Write", sessionID: "ses_test", callID: "call_test" }
const output = { title: "ok", output: undefined as unknown as string, metadata: {} }
await expect(hooks["tool.execute.after"](input, output)).resolves.toBeUndefined()
})
})

View File

@@ -89,10 +89,12 @@ export function createCommentCheckerHooks(config?: CommentCheckerConfig) {
): Promise<void> => {
debugLog("tool.execute.after:", { tool: input.tool, callID: input.callID })
if (!output.output || typeof output.output !== "string") return
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") ||

View File

@@ -43,8 +43,9 @@ export function createEditErrorRecoveryHook(_ctx: PluginInput) {
output: { title: string; output: string; metadata: unknown }
) => {
if (input.tool.toLowerCase() !== "edit") return
if (!output.output || typeof output.output !== "string") return
const outputLower = (output.output ?? "").toLowerCase()
const outputLower = output.output.toLowerCase()
const hasEditError = EDIT_ERROR_PATTERNS.some((pattern) =>
outputLower.includes(pattern.toLowerCase())
)

View File

@@ -21,6 +21,17 @@ describe("createEditErrorRecoveryHook", () => {
metadata: {},
})
describe("#given output.output is undefined", () => {
//#when tool.execute.after is called
//#then should return without throwing
it("#then should not throw", async () => {
const input = createInput("Edit")
const output = { title: "Edit", output: undefined as unknown as string, metadata: {} }
await expect(hook["tool.execute.after"](input, output)).resolves.toBeUndefined()
})
})
describe("#given Edit tool with oldString/newString same error", () => {
describe("#when the error message is detected", () => {
it("#then should append the recovery reminder", async () => {
@@ -102,23 +113,6 @@ 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 () => {

View File

@@ -0,0 +1,17 @@
import { describe, it, expect } from "bun:test"
import { createTaskResumeInfoHook } from "./hook"
describe("createTaskResumeInfoHook", () => {
describe("tool.execute.after", () => {
//#given output.output is undefined
//#when tool.execute.after is called
//#then should return without throwing
it("should not throw when output.output is undefined", async () => {
const hook = createTaskResumeInfoHook()
const input = { tool: "Task", sessionID: "ses_test", callID: "call_test" }
const output = { title: "Result", output: undefined as unknown as string, metadata: {} }
await expect(hook["tool.execute.after"](input, output)).resolves.toBeUndefined()
})
})
})

View File

@@ -21,15 +21,15 @@ export function createTaskResumeInfoHook() {
output: { title: string; output: string; metadata: unknown }
) => {
if (!TARGET_TOOLS.includes(input.tool)) return
const outputText = output.output ?? ""
if (outputText.startsWith("Error:") || outputText.startsWith("Failed")) return
if (outputText.includes("\nto continue:")) return
if (!output.output || typeof output.output !== "string") return
if (output.output.startsWith("Error:") || output.output.startsWith("Failed")) return
if (output.output.includes("\nto continue:")) return
const sessionId = extractSessionId(outputText)
const sessionId = extractSessionId(output.output)
if (!sessionId) return
output.output =
outputText.trimEnd() +
output.output.trimEnd() +
`\n\nto continue: task(session_id="${sessionId}", prompt="...")`
}

View File

@@ -1,101 +0,0 @@
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)
})
})
})
})

View File

@@ -1,14 +1,19 @@
import type { AvailableCategory } from "../agents/dynamic-agent-prompt-builder"
import type { OhMyOpenCodeConfig } from "../config"
import { CATEGORY_DESCRIPTIONS } from "../tools/delegate-task/constants"
import { mergeCategories } from "../shared/merge-categories"
import {
CATEGORY_DESCRIPTIONS,
DEFAULT_CATEGORIES,
} from "../tools/delegate-task/constants"
export function createAvailableCategories(
pluginConfig: OhMyOpenCodeConfig,
): AvailableCategory[] {
const categories = mergeCategories(pluginConfig.categories)
const mergedCategories = pluginConfig.categories
? { ...DEFAULT_CATEGORIES, ...pluginConfig.categories }
: DEFAULT_CATEGORIES
return Object.entries(categories).map(([name, categoryConfig]) => {
return Object.entries(mergedCategories).map(([name, categoryConfig]) => {
const model =
typeof categoryConfig.model === "string" ? categoryConfig.model : undefined

View File

@@ -1,84 +0,0 @@
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")
})
})

View File

@@ -1,18 +0,0 @@
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),
)
}

View File

@@ -1,50 +0,0 @@
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)
})
})

View File

@@ -1,6 +1,5 @@
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(
@@ -39,6 +38,9 @@ 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,
@@ -63,7 +65,6 @@ Original error: ${createResult.error}`)
const sessionID = createResult.data.id
log(`[call_omo_agent] Created session: ${sessionID}`)
subagentSessions.add(sessionID)
return { sessionID, isNew: true }
}
}

View File

@@ -1,47 +0,0 @@
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)
})
})

View File

@@ -1,6 +1,5 @@
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"
@@ -64,6 +63,5 @@ Original error: ${createResult.error}`,
const sessionID = createResult.data.id
log(`[call_omo_agent] Created session: ${sessionID}`)
subagentSessions.add(sessionID)
return { ok: true, sessionID }
}

View File

@@ -13,7 +13,6 @@ export async function promptSubagentSession(
tools: {
...getAgentToolRestrictions(options.agent),
task: false,
question: false,
},
parts: [{ type: "text", text: options.prompt }],
},

View File

@@ -32,10 +32,7 @@ export function resolveCategoryConfig(
const userConfig = userCategories?.[categoryName]
const hasExplicitUserConfig = userConfig !== undefined
if (userConfig?.disable) {
return null
}
// Check if category requires a specific model - bypass if user explicitly provides config
const categoryReq = CATEGORY_MODEL_REQUIREMENTS[categoryName]
if (categoryReq?.requiresModel && availableModels && !hasExplicitUserConfig) {
if (!isModelAvailable(categoryReq.requiresModel, availableModels)) {

View File

@@ -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 { mergeCategories } from "../../shared/merge-categories"
import { DEFAULT_CATEGORIES } from "./constants"
import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent"
import { resolveCategoryConfig } from "./categories"
import { parseModelString } from "./model-string-parser"
@@ -30,8 +30,7 @@ export async function resolveCategoryExecution(
const availableModels = await getAvailableModelsForDelegateTask(client)
const categoryName = args.category!
const enabledCategories = mergeCategories(userCategories)
const categoryExists = enabledCategories[categoryName] !== undefined
const categoryExists = DEFAULT_CATEGORIES[categoryName] !== undefined || userCategories?.[categoryName] !== undefined
const resolved = resolveCategoryConfig(categoryName, {
userCategories,
@@ -42,7 +41,7 @@ export async function resolveCategoryExecution(
if (!resolved) {
const requirement = CATEGORY_MODEL_REQUIREMENTS[categoryName]
const allCategoryNames = Object.keys(enabledCategories).join(", ")
const allCategoryNames = Object.keys({ ...DEFAULT_CATEGORIES, ...userCategories }).join(", ")
if (categoryExists && requirement?.requiresModel) {
return {
@@ -147,7 +146,7 @@ Available categories: ${allCategoryNames}`,
const categoryPromptAppend = resolved.promptAppend || undefined
if (!categoryModel && !actualModel) {
const categoryNames = Object.keys(enabledCategories)
const categoryNames = Object.keys({ ...DEFAULT_CATEGORIES, ...userCategories })
return {
agentToUse: "",
categoryModel: undefined,

View File

@@ -13,6 +13,9 @@ 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,

View File

@@ -1,7 +1,6 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
import type { DelegateTaskArgs, ToolContextWithMetadata, DelegateTaskToolOptions } from "./types"
import { CATEGORY_DESCRIPTIONS } from "./constants"
import { mergeCategories } from "../../shared/merge-categories"
import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "./constants"
import { log } from "../../shared/logger"
import { buildSystemContent } from "./prompt-builder"
import type {
@@ -27,7 +26,7 @@ export { buildSystemContent } from "./prompt-builder"
export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefinition {
const { userCategories } = options
const allCategories = mergeCategories(userCategories)
const allCategories = { ...DEFAULT_CATEGORIES, ...userCategories }
const categoryNames = Object.keys(allCategories)
const categoryExamples = categoryNames.map(k => `'${k}'`).join(", ")