diff --git a/src/features/background-agent/spawner.test.ts b/src/features/background-agent/spawner.test.ts index 4c5ddeaf2..6abf62bda 100644 --- a/src/features/background-agent/spawner.test.ts +++ b/src/features/background-agent/spawner.test.ts @@ -466,4 +466,58 @@ describe("background-agent spawner fallback model promotion", () => { }) expect(promptCalls[0]?.body?.variant).toBe("medium") }) + + test("strips leading zwsp from prompt body agent before promptAsync", async () => { + //#given + const promptCalls: Array<{ body?: { agent?: string } }> = [] + + const client = { + session: { + get: async () => ({ data: { directory: "/parent/dir" } }), + create: async () => ({ data: { id: "ses_child_clean_agent" } }), + promptAsync: async (args?: { body?: { agent?: string } }) => { + promptCalls.push(args ?? {}) + return {} + }, + }, + } + + const task = createTask({ + description: "Test task", + prompt: "Do work", + agent: "\u200Bsisyphus-junior", + parentSessionID: "ses_parent", + parentMessageID: "msg_parent", + }) + + const item = { + task, + input: { + description: task.description, + prompt: task.prompt, + agent: task.agent, + parentSessionID: task.parentSessionID, + parentMessageID: task.parentMessageID, + parentModel: task.parentModel, + parentAgent: task.parentAgent, + model: task.model, + }, + } + + const ctx = { + client, + directory: "/fallback", + concurrencyManager: { release: () => {} }, + tmuxEnabled: false, + onTaskError: () => {}, + } + + //#when + await startTask(item as any, ctx as any) + await new Promise((resolve) => setTimeout(resolve, 0)) + + //#then + expect(promptCalls).toHaveLength(1) + expect(promptCalls[0]?.body?.agent).toBe("sisyphus-junior") + }) }) diff --git a/src/features/background-agent/spawner.ts b/src/features/background-agent/spawner.ts index 3c2fd7e73..b549c706b 100644 --- a/src/features/background-agent/spawner.ts +++ b/src/features/background-agent/spawner.ts @@ -6,6 +6,7 @@ import { applySessionPromptParams } from "../../shared/session-prompt-params-hel import { subagentSessions } from "../claude-code-session-state" import { getTaskToastManager } from "../task-toast-manager" import { isInsideTmux } from "../../shared/tmux" +import { stripAgentListSortPrefix } from "../../shared/agent-display-names" import type { ConcurrencyManager } from "./concurrency" export const FALLBACK_AGENT = "general" @@ -168,11 +169,12 @@ export async function startTask( } : undefined const launchVariant = input.model?.variant + const normalizedAgent = stripAgentListSortPrefix(input.agent) applySessionPromptParams(sessionID, input.model) const promptBody = { - agent: input.agent, + agent: normalizedAgent, ...(launchModel ? { model: launchModel } : {}), ...(launchVariant ? { variant: launchVariant } : {}), system: input.skillContent, @@ -180,7 +182,7 @@ export async function startTask( task: false, call_omo_agent: true, question: false, - ...getAgentToolRestrictions(input.agent), + ...getAgentToolRestrictions(normalizedAgent), }, parts: [createInternalAgentTextPart(input.prompt)], } diff --git a/src/hooks/auto-update-checker/checker/plugin-entry.test.ts b/src/hooks/auto-update-checker/checker/plugin-entry.test.ts index c621099b6..341839af0 100644 --- a/src/hooks/auto-update-checker/checker/plugin-entry.test.ts +++ b/src/hooks/auto-update-checker/checker/plugin-entry.test.ts @@ -4,6 +4,7 @@ import * as fs from "node:fs" import * as os from "node:os" import * as path from "node:path" import { PACKAGE_NAME } from "../constants" +import { LEGACY_PLUGIN_NAME, PLUGIN_NAME } from "../../../shared/plugin-identity" type PluginEntryResult = { entry: string @@ -120,6 +121,64 @@ describe("findPluginEntry", () => { expect(pluginInfo?.pinnedVersion).toBe("3.5.2") }) + test("finds preferred plugin entry", async () => { + // #given preferred plugin entry is configured + fs.writeFileSync(configPath, JSON.stringify({ plugin: [PLUGIN_NAME] })) + + // #when plugin entry is detected + const execution = runFindPluginEntry(temporaryDirectory) + + // #then preferred entry is returned + expect(execution.status).toBe(0) + const pluginInfo = JSON.parse(execution.stdout.trim()) as PluginEntryResult + expect(pluginInfo?.entry).toBe(PLUGIN_NAME) + expect(pluginInfo?.isPinned).toBe(false) + expect(pluginInfo?.pinnedVersion).toBeNull() + }) + + test("finds legacy plugin entry", async () => { + // #given legacy plugin entry is configured + fs.writeFileSync(configPath, JSON.stringify({ plugin: [LEGACY_PLUGIN_NAME] })) + + // #when plugin entry is detected + const execution = runFindPluginEntry(temporaryDirectory) + + // #then legacy entry is returned + expect(execution.status).toBe(0) + const pluginInfo = JSON.parse(execution.stdout.trim()) as PluginEntryResult + expect(pluginInfo?.entry).toBe(LEGACY_PLUGIN_NAME) + expect(pluginInfo?.isPinned).toBe(false) + expect(pluginInfo?.pinnedVersion).toBeNull() + }) + + test("finds preferred plugin entry with pinned version", async () => { + // #given preferred plugin entry includes semver version + fs.writeFileSync(configPath, JSON.stringify({ plugin: [`${PLUGIN_NAME}@3.15.0`] })) + + // #when plugin entry is detected + const execution = runFindPluginEntry(temporaryDirectory) + + // #then preferred versioned entry is returned + expect(execution.status).toBe(0) + const pluginInfo = JSON.parse(execution.stdout.trim()) as PluginEntryResult + expect(pluginInfo?.entry).toBe(`${PLUGIN_NAME}@3.15.0`) + expect(pluginInfo?.isPinned).toBe(true) + expect(pluginInfo?.pinnedVersion).toBe("3.15.0") + }) + + test("returns null for unrelated plugin entry", async () => { + // #given unrelated plugin entry is configured + fs.writeFileSync(configPath, JSON.stringify({ plugin: ["some-other-plugin"] })) + + // #when plugin entry is detected + const execution = runFindPluginEntry(temporaryDirectory) + + // #then no matching entry is returned + expect(execution.status).toBe(0) + const pluginInfo = JSON.parse(execution.stdout.trim()) as PluginEntryResult + expect(pluginInfo).toBeNull() + }) + test("reads user config from profile dir even when OPENCODE_CONFIG_DIR changes after import", async () => { // #given profile-specific user config after module import const profileConfigDir = path.join(temporaryDirectory, "profiles", "today") diff --git a/src/hooks/auto-update-checker/checker/plugin-entry.ts b/src/hooks/auto-update-checker/checker/plugin-entry.ts index f204d61f1..55260c94e 100644 --- a/src/hooks/auto-update-checker/checker/plugin-entry.ts +++ b/src/hooks/auto-update-checker/checker/plugin-entry.ts @@ -3,6 +3,7 @@ import type { OpencodeConfig } from "../types" import { PACKAGE_NAME } from "../constants" import { getConfigPaths } from "./config-paths" import { stripJsonComments } from "./jsonc-strip" +import { LEGACY_PLUGIN_NAME, PLUGIN_NAME } from "../../../shared/plugin-identity" export interface PluginEntryInfo { entry: string @@ -12,6 +13,7 @@ export interface PluginEntryInfo { } const EXACT_SEMVER_REGEX = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/ +const MATCH_PLUGIN_NAMES = [PACKAGE_NAME, PLUGIN_NAME, LEGACY_PLUGIN_NAME] export function findPluginEntry(directory: string): PluginEntryInfo | null { for (const configPath of getConfigPaths(directory)) { @@ -22,13 +24,15 @@ export function findPluginEntry(directory: string): PluginEntryInfo | null { const plugins = config.plugin ?? [] for (const entry of plugins) { - if (entry === PACKAGE_NAME) { - return { entry, isPinned: false, pinnedVersion: null, configPath } - } - if (entry.startsWith(`${PACKAGE_NAME}@`)) { - const pinnedVersion = entry.slice(PACKAGE_NAME.length + 1) - const isPinned = EXACT_SEMVER_REGEX.test(pinnedVersion.trim()) - return { entry, isPinned, pinnedVersion, configPath } + for (const pluginName of MATCH_PLUGIN_NAMES) { + if (entry === pluginName) { + return { entry, isPinned: false, pinnedVersion: null, configPath } + } + if (entry.startsWith(`${pluginName}@`)) { + const pinnedVersion = entry.slice(pluginName.length + 1) + const isPinned = EXACT_SEMVER_REGEX.test(pinnedVersion.trim()) + return { entry, isPinned, pinnedVersion, configPath } + } } } } catch { diff --git a/src/hooks/keyword-detector/hook-ralph-loop.test.ts b/src/hooks/keyword-detector/hook-ralph-loop.test.ts index 0ba0a6d27..ce0a6f066 100644 --- a/src/hooks/keyword-detector/hook-ralph-loop.test.ts +++ b/src/hooks/keyword-detector/hook-ralph-loop.test.ts @@ -86,6 +86,44 @@ describe("keyword-detector ralph-loop activation", () => { expect(startLoopCalls[0].options.ultrawork).toBe(true) }) + test("#given ulw mentioned mid-sentence #when chat.message fires #then ralph-loop startLoop is not invoked", async () => { + // given + setMainSession("main-session") + const startLoopCalls: StartLoopCall[] = [] + const ralphLoop = createMockRalphLoop(startLoopCalls) + const hook = createKeywordDetectorHook(createMockPluginInput(), undefined, ralphLoop) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "I think ulw is cool" }], + } + + // when + await hook["chat.message"]({ sessionID: "main-session", agent: "sisyphus" }, output) + + // then + expect(startLoopCalls).toHaveLength(0) + expect(output.parts[0]?.text).toBe("I think ulw is cool") + }) + + test("#given question about ultrawork #when chat.message fires #then ralph-loop startLoop is not invoked", async () => { + // given + setMainSession("main-session") + const startLoopCalls: StartLoopCall[] = [] + const ralphLoop = createMockRalphLoop(startLoopCalls) + const hook = createKeywordDetectorHook(createMockPluginInput(), undefined, ralphLoop) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "what is ultrawork?" }], + } + + // when + await hook["chat.message"]({ sessionID: "main-session", agent: "sisyphus" }, output) + + // then + expect(startLoopCalls).toHaveLength(0) + expect(output.parts[0]?.text).toBe("what is ultrawork?") + }) + test("#given non-ulw message #when chat.message fires #then ralph-loop startLoop is not invoked", async () => { // given setMainSession("main-session") diff --git a/src/hooks/keyword-detector/hook.ts b/src/hooks/keyword-detector/hook.ts index 1d35bbe3f..ea6348419 100644 --- a/src/hooks/keyword-detector/hook.ts +++ b/src/hooks/keyword-detector/hook.ts @@ -16,11 +16,16 @@ import type { RalphLoopHook } from "../ralph-loop" import { parseRalphLoopArguments } from "../ralph-loop/command-arguments" const ULTRAWORK_KEYWORD_PATTERN = /\b(ultrawork|ulw)\b/i +const LEADING_ULTRAWORK_PATTERN = /^\s*(ultrawork|ulw)\b/i function extractUltraworkTask(cleanText: string): string { return cleanText.replace(ULTRAWORK_KEYWORD_PATTERN, "").trim() } +function hasLeadingUltraworkKeyword(cleanText: string): boolean { + return LEADING_ULTRAWORK_PATTERN.test(cleanText) +} + export function createKeywordDetectorHook( ctx: PluginInput, _collector?: ContextCollector, @@ -76,6 +81,16 @@ export function createKeywordDetectorHook( } } + if (!hasLeadingUltraworkKeyword(cleanText)) { + const preFilterCount = detectedKeywords.length + detectedKeywords = detectedKeywords.filter((k) => k.type !== "ultrawork") + if (preFilterCount > detectedKeywords.length) { + log(`[keyword-detector] Filtered non-leading ultrawork keyword`, { + sessionID: input.sessionID, + }) + } + } + if (detectedKeywords.length === 0) { return } diff --git a/src/tools/delegate-task/background-task.test.ts b/src/tools/delegate-task/background-task.test.ts index 0d95dd1dd..84a7bc644 100644 --- a/src/tools/delegate-task/background-task.test.ts +++ b/src/tools/delegate-task/background-task.test.ts @@ -204,6 +204,50 @@ describeFn("executeBackgroundTask output/session metadata compatibility", () => ]) }) + testFn("strips leading zwsp from agent name before launching background task", async () => { + //#given - display-sorted agent names should be normalized before manager launch + const launchCalls: unknown[] = [] + const manager = { + launch: async (input: unknown) => { + launchCalls.push(input) + return { + id: "bg_clean_agent", + sessionID: "ses_clean_agent", + description: "Clean agent", + agent: "sisyphus-junior", + status: "running", + } + }, + getTask: () => ({ sessionID: "ses_clean_agent" }), + } + + //#when + await executeBackgroundTask( + { + description: "Clean agent", + prompt: "check", + run_in_background: true, + load_skills: [], + }, + { + sessionID: "ses_parent", + callID: "call_clean_agent", + metadata: async () => {}, + abort: new AbortController().signal, + }, + { manager }, + { sessionID: "ses_parent", messageID: "msg_clean_agent" }, + "\u200Bsisyphus-junior", + undefined, + undefined, + undefined, + ) + + //#then + expectFn(launchCalls).toHaveLength(1) + expectFn((launchCalls[0] as { agent: string }).agent).toBe("sisyphus-junior") + }) + testFn("keeps launched background task alive when parent aborts before session id resolves", async () => { //#given - parallel tool execution can abort the parent call after launch succeeds const metadataCalls: any[] = [] diff --git a/src/tools/delegate-task/background-task.ts b/src/tools/delegate-task/background-task.ts index 0dbb042ab..184325ec9 100644 --- a/src/tools/delegate-task/background-task.ts +++ b/src/tools/delegate-task/background-task.ts @@ -10,6 +10,7 @@ import { getSessionTools } from "../../shared/session-tools-store" import { SessionCategoryRegistry } from "../../shared/session-category-registry" import { QUESTION_DENIED_SESSION_PERMISSION } from "../../shared/question-denied-session-permission" import { setSessionFallbackChain } from "../../hooks/model-fallback/hook" +import { stripAgentListSortPrefix } from "../../shared/agent-display-names" function continueSessionSetup(args: { taskID: string @@ -62,11 +63,12 @@ export async function executeBackgroundTask( try { const tddEnabled = executorCtx.sisyphusAgentConfig?.tdd - const effectivePrompt = buildTaskPrompt(args.prompt, agentToUse, tddEnabled) + const normalizedAgent = stripAgentListSortPrefix(agentToUse) + const effectivePrompt = buildTaskPrompt(args.prompt, normalizedAgent, tddEnabled) const task = await manager.launch({ description: args.description, prompt: effectivePrompt, - agent: agentToUse, + agent: normalizedAgent, parentSessionID: parentContext.sessionID, parentMessageID: parentContext.messageID, parentModel: parentContext.model, @@ -156,7 +158,7 @@ Do NOT call background_output now. Wait for notification first return formatDetailedError(error, { operation: "Launch background task", args, - agent: agentToUse, + agent: stripAgentListSortPrefix(agentToUse), category: args.category, }) }