fix(merge): resolve dev conflicts for openclaw branch

This commit is contained in:
GeonWoo Jeon (Jay)
2026-04-09 12:23:38 +09:00
177 changed files with 5633 additions and 483 deletions
@@ -190,10 +190,10 @@ describe("executeSync", () => {
expect(promptInput?.body.temperature).toBe(0.12)
expect(promptInput?.body.topP).toBe(0.34)
expect(promptInput?.body.options).toEqual({
maxTokens: 5678,
reasoningEffort: "medium",
thinking: { type: "disabled" },
})
expect(promptInput?.body.maxOutputTokens).toBe(5678)
})
test("records metadata with description and created session id", async () => {
+1 -1
View File
@@ -43,12 +43,12 @@ function buildPromptGenerationParams(model: DelegatedModelConfig | undefined): R
const promptOptions: Record<string, unknown> = {
...(model.reasoningEffort ? { reasoningEffort: model.reasoningEffort } : {}),
...(model.thinking ? { thinking: model.thinking } : {}),
...(model.maxTokens !== undefined ? { maxTokens: model.maxTokens } : {}),
}
return {
...(model.temperature !== undefined ? { temperature: model.temperature } : {}),
...(model.top_p !== undefined ? { topP: model.top_p } : {}),
...(model.maxTokens !== undefined ? { maxOutputTokens: model.maxTokens } : {}),
...(Object.keys(promptOptions).length > 0 ? { options: promptOptions } : {}),
}
}
@@ -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,
})
}
+2 -4
View File
@@ -325,7 +325,7 @@ export const PLAN_AGENT_NAMES = ["plan"]
export function isPlanAgent(agentName: string | undefined): boolean {
if (!agentName) return false
const lowerName = agentName.toLowerCase().trim()
return PLAN_AGENT_NAMES.some(name => lowerName === name || lowerName.includes(name))
return PLAN_AGENT_NAMES.some(name => lowerName === name)
}
/**
@@ -342,7 +342,5 @@ export function isPlanFamily(category: string | undefined): boolean
export function isPlanFamily(category: string | undefined): boolean {
if (!category) return false
const lowerCategory = category.toLowerCase().trim()
return PLAN_FAMILY_NAMES.some(
(name) => lowerCategory === name || lowerCategory.includes(name)
)
return PLAN_FAMILY_NAMES.some((name) => lowerCategory === name)
}
+3 -2
View File
@@ -89,9 +89,10 @@ Create the work plan directly - that's your job as the planning agent.`,
const callableAgents = agents.filter((agent) => isTaskCallableAgentMode(agent.mode))
const resolvedDisplayName = getAgentDisplayName(agentToUse)
const resolvedDisplayName = getAgentDisplayName(agentToUse).replace(/^\u200B+/, "")
const normalizedAgentToUse = agentToUse.replace(/^\u200B+/, "")
const matchedAgent = callableAgents.find(
(agent) => agent.name.toLowerCase() === agentToUse.toLowerCase()
(agent) => agent.name.toLowerCase() === normalizedAgentToUse.toLowerCase()
|| agent.name.toLowerCase() === resolvedDisplayName.toLowerCase()
)
if (!matchedAgent) {
@@ -277,15 +277,15 @@ bunDescribe("sendSyncPrompt", () => {
bunExpect(promptArgs.body.options).toEqual({
reasoningEffort: "high",
thinking: { type: "disabled" },
maxTokens: 4096,
})
bunExpect(promptArgs.body.maxOutputTokens).toBe(4096)
bunExpect(getSessionPromptParams("test-session")).toEqual({
temperature: 0.4,
topP: 0.7,
maxOutputTokens: 4096,
options: {
reasoningEffort: "high",
thinking: { type: "disabled" },
maxTokens: 4096,
},
})
})
@@ -30,12 +30,12 @@ function buildPromptGenerationParams(model: DelegatedModelConfig | undefined): R
const promptOptions: Record<string, unknown> = {
...(model.reasoningEffort ? { reasoningEffort: model.reasoningEffort } : {}),
...(model.thinking ? { thinking: model.thinking } : {}),
...(model.maxTokens !== undefined ? { maxTokens: model.maxTokens } : {}),
}
return {
...(model.temperature !== undefined ? { temperature: model.temperature } : {}),
...(model.top_p !== undefined ? { topP: model.top_p } : {}),
...(model.maxTokens !== undefined ? { maxOutputTokens: model.maxTokens } : {}),
...(Object.keys(promptOptions).length > 0 ? { options: promptOptions } : {}),
}
}
@@ -80,7 +80,7 @@ export async function sendSyncPrompt(
const promptArgs = {
path: { id: input.sessionID },
body: {
agent: input.agentToUse,
agent: input.agentToUse.replace(/^\u200B+/, ""),
system: input.systemContent,
tools,
parts: [createInternalAgentTextPart(effectivePrompt)],
+133
View File
@@ -282,6 +282,139 @@ describe("executeSyncTask - cleanup on error paths", () => {
expect(deleteCalls.length).toBe(1)
expect(deleteCalls[0]).toBe("ses_test_12345678")
})
test("depth regression: blocks spawn when reserveSubagentSpawn throws depth limit error", async () => {
// This is a smoke test guarding against regressions where the depth limit
// would be silently bypassed (e.g. via a fallback path that hardcodes
// childDepth: 1).
const mockClient = {
session: {
create: async () => ({ data: { id: "ses_test_12345678" } }),
},
}
const { executeSyncTask } = require("./sync-task")
const reserveSubagentSpawn = mock(async () => {
throw new Error(
"Subagent spawn blocked: child depth 4 exceeds background_task.maxDepth=3. Parent session: parent. Root session: root. Continue in an existing subagent session instead of spawning another."
)
})
const deps = {
createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }),
sendSyncPrompt: async () => null,
pollSyncSession: async () => null,
fetchSyncResult: async () => ({ ok: true as const, textContent: "Result" }),
}
const mockCtx = {
sessionID: "parent-session",
callID: "call-123",
metadata: () => {},
}
const mockExecutorCtx = {
manager: { reserveSubagentSpawn },
client: mockClient,
directory: "/tmp",
onSyncSessionCreated: null,
}
const args = {
prompt: "test prompt",
description: "test task",
category: "test",
load_skills: [],
run_in_background: false,
command: null,
}
//#when - executeSyncTask is called from a session at max depth
const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, {
sessionID: "parent-session",
}, "test-agent", undefined, undefined, undefined, undefined, deps)
//#then - should propagate the depth limit error and NOT create the session
expect(result).toContain("Subagent spawn blocked")
expect(result).toContain("child depth 4")
expect(result).toContain("maxDepth=3")
expect(reserveSubagentSpawn).toHaveBeenCalledWith("parent-session")
// critical: createSyncSession must NOT have been called -- if it was,
// the depth guard was bypassed.
expect(addCalls.length).toBe(0)
})
test("depth regression: does not silently fall back to childDepth: 1 when manager methods are present", async () => {
// Guards against the dangerous fallback path in sync-task.ts that
// hardcodes childDepth: 1 if reserveSubagentSpawn / assertCanSpawn are
// not functions. With a real manager present, the fallback must NOT be
// taken.
const mockClient = {
session: {
create: async () => ({ data: { id: "ses_test_12345678" } }),
},
}
const { executeSyncTask } = require("./sync-task")
let reservedDepth: number | undefined
const commit = mock(() => 1)
const rollback = mock(() => {})
const reserveSubagentSpawn = mock(async () => {
// Return a depth that proves the real manager was consulted
reservedDepth = 3
return {
spawnContext: { rootSessionID: "root", parentDepth: 2, childDepth: 3 },
descendantCount: 5,
commit,
rollback,
}
})
const deps = {
createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }),
sendSyncPrompt: async () => null,
pollSyncSession: async () => null,
fetchSyncResult: async () => ({ ok: true as const, textContent: "Result" }),
}
const metadataCalls: any[] = []
const mockCtx = {
sessionID: "parent-session",
callID: "call-123",
metadata: (input: any) => { metadataCalls.push(input) },
}
const mockExecutorCtx = {
manager: { reserveSubagentSpawn },
client: mockClient,
directory: "/tmp",
onSyncSessionCreated: null,
}
const args = {
prompt: "test prompt",
description: "test task",
category: "test",
load_skills: [],
run_in_background: false,
command: null,
}
//#when
await executeSyncTask(args, mockCtx, mockExecutorCtx, {
sessionID: "parent-session",
}, "test-agent", undefined, undefined, undefined, undefined, deps)
//#then - the spawnDepth recorded in metadata MUST match what reserveSubagentSpawn returned
expect(reservedDepth).toBe(3)
const taskMeta = metadataCalls.find((c) => c.metadata?.spawnDepth !== undefined)
expect(taskMeta).toBeDefined()
expect(taskMeta.metadata.spawnDepth).toBe(3) // NOT 1 (the fallback value)
})
})
export {}
+23 -8
View File
@@ -37,14 +37,29 @@ export async function executeSyncTask(
spawnReservation = await manager.reserveSubagentSpawn(parentContext.sessionID)
}
const spawnContext = spawnReservation?.spawnContext
?? (typeof manager?.assertCanSpawn === "function"
? await manager.assertCanSpawn(parentContext.sessionID)
: {
rootSessionID: parentContext.sessionID,
parentDepth: 0,
childDepth: 1,
})
// Depth/descendant guard. We must NOT silently fall back to childDepth: 1
// when the manager is unavailable or lacks the spawn methods, because that
// would let subagents recurse without bound. The only safe fallback is
// when the manager genuinely cannot enforce limits (legacy SDK), in which
// case we still record childDepth: 1 but log a warning so regressions are
// visible.
let spawnContext: { rootSessionID: string; parentDepth: number; childDepth: number }
if (spawnReservation?.spawnContext) {
spawnContext = spawnReservation.spawnContext
} else if (typeof manager?.assertCanSpawn === "function") {
spawnContext = await manager.assertCanSpawn(parentContext.sessionID)
} else {
log(
"[task] WARNING: BackgroundManager has no spawn enforcement methods (reserveSubagentSpawn / assertCanSpawn). " +
"Depth and descendant limits cannot be enforced for this task. This indicates an old SDK or a misconfiguration.",
{ parentSessionID: parentContext.sessionID }
)
spawnContext = {
rootSessionID: parentContext.sessionID,
parentDepth: 0,
childDepth: 1,
}
}
const createSessionResult = await deps.createSyncSession(client, {
parentSessionID: parentContext.sessionID,
+2 -2
View File
@@ -180,8 +180,8 @@ describe("sisyphus-task", () => {
//#given / #when
const result = isPlanAgent("planner")
//#then - "planner" contains "plan" so it matches via includes
expect(result).toBe(true)
//#then - "planner" is NOT an exact match for "plan" (T37 exact match fix)
expect(result).toBe(false)
})
test("returns true for case-insensitive match 'PLAN'", () => {
+1
View File
@@ -166,6 +166,7 @@ export function createSkillMcpTool(options: SkillMcpToolOptions): ToolDefinition
serverName: args.mcp_name,
skillName: found.skill.name,
sessionID,
scope: found.skill.scope,
}
const context: SkillMcpServerContext = {
@@ -23,6 +23,7 @@ export async function formatMcpCapabilities(
serverName,
skillName: skill.name,
sessionID,
scope: skill.scope,
}
const context: SkillMcpServerContext = {
config,
+2 -3
View File
@@ -37,8 +37,7 @@ Returns summary format: id, subject, status, owner, blockedBy (not full descript
return JSON.stringify({ tasks: [] })
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const allTasks: any[] = []
const allTasks: TaskObject[] = []
for (const fileId of files) {
const task = readJsonSafe(join(taskDir, `${fileId}.json`), TaskObjectSchema)
if (task) {
@@ -56,7 +55,7 @@ Returns summary format: id, subject, status, owner, blockedBy (not full descript
// Build summary with filtered blockedBy
const summaries: TaskSummary[] = activeTasks.map((task) => {
// Filter blockedBy to only include unresolved (non-completed) blockers
const unresolvedBlockers = (task.blockedBy ?? []).filter((blockerId: string) => {
const unresolvedBlockers = task.blockedBy.filter((blockerId: string) => {
const blockerTask = taskMap.get(blockerId)
// Include if blocker doesn't exist (missing) or if it's not completed
return !blockerTask || blockerTask.status !== "completed"
+2 -2
View File
@@ -114,12 +114,12 @@ async function handleUpdate(
const addBlocks = args.addBlocks as string[] | undefined;
if (addBlocks) {
task.blocks = [...new Set([...(task.blocks ?? []), ...addBlocks])];
task.blocks = [...new Set([...task.blocks, ...addBlocks])];
}
const addBlockedBy = args.addBlockedBy as string[] | undefined;
if (addBlockedBy) {
task.blockedBy = [...new Set([...(task.blockedBy ?? []), ...addBlockedBy])];
task.blockedBy = [...new Set([...task.blockedBy, ...addBlockedBy])];
}
if (validatedArgs.metadata !== undefined) {