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:
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user