fix(merge): resolve dev conflicts for openclaw branch
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user