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")
|
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 { subagentSessions } from "../claude-code-session-state"
|
||||||
import { getTaskToastManager } from "../task-toast-manager"
|
import { getTaskToastManager } from "../task-toast-manager"
|
||||||
import { isInsideTmux } from "../../shared/tmux"
|
import { isInsideTmux } from "../../shared/tmux"
|
||||||
|
import { stripAgentListSortPrefix } from "../../shared/agent-display-names"
|
||||||
import type { ConcurrencyManager } from "./concurrency"
|
import type { ConcurrencyManager } from "./concurrency"
|
||||||
|
|
||||||
export const FALLBACK_AGENT = "general"
|
export const FALLBACK_AGENT = "general"
|
||||||
@@ -168,11 +169,12 @@ export async function startTask(
|
|||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
const launchVariant = input.model?.variant
|
const launchVariant = input.model?.variant
|
||||||
|
const normalizedAgent = stripAgentListSortPrefix(input.agent)
|
||||||
|
|
||||||
applySessionPromptParams(sessionID, input.model)
|
applySessionPromptParams(sessionID, input.model)
|
||||||
|
|
||||||
const promptBody = {
|
const promptBody = {
|
||||||
agent: input.agent,
|
agent: normalizedAgent,
|
||||||
...(launchModel ? { model: launchModel } : {}),
|
...(launchModel ? { model: launchModel } : {}),
|
||||||
...(launchVariant ? { variant: launchVariant } : {}),
|
...(launchVariant ? { variant: launchVariant } : {}),
|
||||||
system: input.skillContent,
|
system: input.skillContent,
|
||||||
@@ -180,7 +182,7 @@ export async function startTask(
|
|||||||
task: false,
|
task: false,
|
||||||
call_omo_agent: true,
|
call_omo_agent: true,
|
||||||
question: false,
|
question: false,
|
||||||
...getAgentToolRestrictions(input.agent),
|
...getAgentToolRestrictions(normalizedAgent),
|
||||||
},
|
},
|
||||||
parts: [createInternalAgentTextPart(input.prompt)],
|
parts: [createInternalAgentTextPart(input.prompt)],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as fs from "node:fs"
|
|||||||
import * as os from "node:os"
|
import * as os from "node:os"
|
||||||
import * as path from "node:path"
|
import * as path from "node:path"
|
||||||
import { PACKAGE_NAME } from "../constants"
|
import { PACKAGE_NAME } from "../constants"
|
||||||
|
import { LEGACY_PLUGIN_NAME, PLUGIN_NAME } from "../../../shared/plugin-identity"
|
||||||
|
|
||||||
type PluginEntryResult = {
|
type PluginEntryResult = {
|
||||||
entry: string
|
entry: string
|
||||||
@@ -120,6 +121,64 @@ describe("findPluginEntry", () => {
|
|||||||
expect(pluginInfo?.pinnedVersion).toBe("3.5.2")
|
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 () => {
|
test("reads user config from profile dir even when OPENCODE_CONFIG_DIR changes after import", async () => {
|
||||||
// #given profile-specific user config after module import
|
// #given profile-specific user config after module import
|
||||||
const profileConfigDir = path.join(temporaryDirectory, "profiles", "today")
|
const profileConfigDir = path.join(temporaryDirectory, "profiles", "today")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { OpencodeConfig } from "../types"
|
|||||||
import { PACKAGE_NAME } from "../constants"
|
import { PACKAGE_NAME } from "../constants"
|
||||||
import { getConfigPaths } from "./config-paths"
|
import { getConfigPaths } from "./config-paths"
|
||||||
import { stripJsonComments } from "./jsonc-strip"
|
import { stripJsonComments } from "./jsonc-strip"
|
||||||
|
import { LEGACY_PLUGIN_NAME, PLUGIN_NAME } from "../../../shared/plugin-identity"
|
||||||
|
|
||||||
export interface PluginEntryInfo {
|
export interface PluginEntryInfo {
|
||||||
entry: string
|
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 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 {
|
export function findPluginEntry(directory: string): PluginEntryInfo | null {
|
||||||
for (const configPath of getConfigPaths(directory)) {
|
for (const configPath of getConfigPaths(directory)) {
|
||||||
@@ -22,13 +24,15 @@ export function findPluginEntry(directory: string): PluginEntryInfo | null {
|
|||||||
const plugins = config.plugin ?? []
|
const plugins = config.plugin ?? []
|
||||||
|
|
||||||
for (const entry of plugins) {
|
for (const entry of plugins) {
|
||||||
if (entry === PACKAGE_NAME) {
|
for (const pluginName of MATCH_PLUGIN_NAMES) {
|
||||||
return { entry, isPinned: false, pinnedVersion: null, configPath }
|
if (entry === pluginName) {
|
||||||
}
|
return { entry, isPinned: false, pinnedVersion: null, configPath }
|
||||||
if (entry.startsWith(`${PACKAGE_NAME}@`)) {
|
}
|
||||||
const pinnedVersion = entry.slice(PACKAGE_NAME.length + 1)
|
if (entry.startsWith(`${pluginName}@`)) {
|
||||||
const isPinned = EXACT_SEMVER_REGEX.test(pinnedVersion.trim())
|
const pinnedVersion = entry.slice(pluginName.length + 1)
|
||||||
return { entry, isPinned, pinnedVersion, configPath }
|
const isPinned = EXACT_SEMVER_REGEX.test(pinnedVersion.trim())
|
||||||
|
return { entry, isPinned, pinnedVersion, configPath }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -86,6 +86,44 @@ describe("keyword-detector ralph-loop activation", () => {
|
|||||||
expect(startLoopCalls[0].options.ultrawork).toBe(true)
|
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 () => {
|
test("#given non-ulw message #when chat.message fires #then ralph-loop startLoop is not invoked", async () => {
|
||||||
// given
|
// given
|
||||||
setMainSession("main-session")
|
setMainSession("main-session")
|
||||||
|
|||||||
@@ -16,11 +16,16 @@ import type { RalphLoopHook } from "../ralph-loop"
|
|||||||
import { parseRalphLoopArguments } from "../ralph-loop/command-arguments"
|
import { parseRalphLoopArguments } from "../ralph-loop/command-arguments"
|
||||||
|
|
||||||
const ULTRAWORK_KEYWORD_PATTERN = /\b(ultrawork|ulw)\b/i
|
const ULTRAWORK_KEYWORD_PATTERN = /\b(ultrawork|ulw)\b/i
|
||||||
|
const LEADING_ULTRAWORK_PATTERN = /^\s*(ultrawork|ulw)\b/i
|
||||||
|
|
||||||
function extractUltraworkTask(cleanText: string): string {
|
function extractUltraworkTask(cleanText: string): string {
|
||||||
return cleanText.replace(ULTRAWORK_KEYWORD_PATTERN, "").trim()
|
return cleanText.replace(ULTRAWORK_KEYWORD_PATTERN, "").trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasLeadingUltraworkKeyword(cleanText: string): boolean {
|
||||||
|
return LEADING_ULTRAWORK_PATTERN.test(cleanText)
|
||||||
|
}
|
||||||
|
|
||||||
export function createKeywordDetectorHook(
|
export function createKeywordDetectorHook(
|
||||||
ctx: PluginInput,
|
ctx: PluginInput,
|
||||||
_collector?: ContextCollector,
|
_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) {
|
if (detectedKeywords.length === 0) {
|
||||||
return
|
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 () => {
|
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
|
//#given - parallel tool execution can abort the parent call after launch succeeds
|
||||||
const metadataCalls: any[] = []
|
const metadataCalls: any[] = []
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { getSessionTools } from "../../shared/session-tools-store"
|
|||||||
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||||
import { QUESTION_DENIED_SESSION_PERMISSION } from "../../shared/question-denied-session-permission"
|
import { QUESTION_DENIED_SESSION_PERMISSION } from "../../shared/question-denied-session-permission"
|
||||||
import { setSessionFallbackChain } from "../../hooks/model-fallback/hook"
|
import { setSessionFallbackChain } from "../../hooks/model-fallback/hook"
|
||||||
|
import { stripAgentListSortPrefix } from "../../shared/agent-display-names"
|
||||||
|
|
||||||
function continueSessionSetup(args: {
|
function continueSessionSetup(args: {
|
||||||
taskID: string
|
taskID: string
|
||||||
@@ -62,11 +63,12 @@ export async function executeBackgroundTask(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const tddEnabled = executorCtx.sisyphusAgentConfig?.tdd
|
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({
|
const task = await manager.launch({
|
||||||
description: args.description,
|
description: args.description,
|
||||||
prompt: effectivePrompt,
|
prompt: effectivePrompt,
|
||||||
agent: agentToUse,
|
agent: normalizedAgent,
|
||||||
parentSessionID: parentContext.sessionID,
|
parentSessionID: parentContext.sessionID,
|
||||||
parentMessageID: parentContext.messageID,
|
parentMessageID: parentContext.messageID,
|
||||||
parentModel: parentContext.model,
|
parentModel: parentContext.model,
|
||||||
@@ -156,7 +158,7 @@ Do NOT call background_output now. Wait for <system-reminder> notification first
|
|||||||
return formatDetailedError(error, {
|
return formatDetailedError(error, {
|
||||||
operation: "Launch background task",
|
operation: "Launch background task",
|
||||||
args,
|
args,
|
||||||
agent: agentToUse,
|
agent: stripAgentListSortPrefix(agentToUse),
|
||||||
category: args.category,
|
category: args.category,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user