Merge pull request #3225 from code-yeongyu/fix/agent-name-safety

fix(agent): narrow ULW auto-start + strip ZWSP on background path + fix auto-update identity
This commit is contained in:
YeonGyu-Kim
2026-04-08 17:40:48 +09:00
committed by GitHub
8 changed files with 230 additions and 12 deletions
@@ -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")
})
})
+4 -2
View File
@@ -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)],
}
@@ -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")
@@ -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 {
@@ -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<string, unknown>,
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<string, unknown>,
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")
+15
View File
@@ -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
}
@@ -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[] = []
+5 -3
View File
@@ -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 <system-reminder> notification first
return formatDetailedError(error, {
operation: "Launch background task",
args,
agent: agentToUse,
agent: stripAgentListSortPrefix(agentToUse),
category: args.category,
})
}