Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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).
|
||||
|
||||
|
||||
16
package.json
16
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "3.5.0",
|
||||
"version": "3.5.2",
|
||||
"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.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"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@ast-grep/cli",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-arm64",
|
||||
"version": "3.5.0",
|
||||
"version": "3.5.2",
|
||||
"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.2",
|
||||
"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.2",
|
||||
"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.2",
|
||||
"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.2",
|
||||
"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.2",
|
||||
"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.2",
|
||||
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1319,6 +1319,46 @@
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -1520,7 +1520,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 +1562,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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
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 }],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}...`)
|
||||
|
||||
Reference in New Issue
Block a user