fix(background-task): disambiguate background output task ids

This commit is contained in:
YeonGyu-Kim
2026-05-10 14:29:37 +09:00
parent e92f81eba9
commit 50699e3af1
7 changed files with 108 additions and 7 deletions
@@ -65,4 +65,46 @@ describe("createBackgroundOutput metadata", () => {
clearPendingStore()
})
test("explains when a session id is passed as the background task id", async () => {
// #given
const task: BackgroundTask = {
id: "bg-real-task",
sessionId: "ses-child-task",
parentSessionId: "main-1",
parentMessageId: "msg-1",
description: "background task",
prompt: "do work",
agent: "test-agent",
status: "completed",
}
const manager: BackgroundOutputManager = {
getTask: id => (id === task.id ? task : undefined),
}
const client: BackgroundOutputClient = {
session: {
messages: async () => ({ data: [] }),
},
}
const tool = createBackgroundOutput(manager, client)
const context = {
sessionID: "test-session",
messageID: "test-message",
agent: "test-agent",
directory: projectDir,
worktree: projectDir,
abort: new AbortController().signal,
metadata: () => {},
ask: async () => {},
callID: "call-1",
} satisfies ToolContextWithCallID
// #when
const output = await tool.execute({ task_id: "ses-child-task" }, context)
// #then
expect(output).toContain("background_output expects a background task ID")
expect(output).toContain("bg_")
expect(output).toContain('session_read(session_id="ses-child-task")')
})
})
@@ -36,6 +36,22 @@ function appendTimeoutNote(output: string, timeoutMs: number): string {
return `${output}\n\n> **Timed out waiting** after ${timeoutMs}ms. Task is still running; showing latest available output.`
}
function isSessionId(value: string): boolean {
return /^ses[_-]/.test(value)
}
function formatTaskNotFoundMessage(taskId: string): string {
if (!isSessionId(taskId)) {
return `Task not found: ${taskId}`
}
return `Task not found: ${taskId}
background_output expects a background task ID such as \`bg_...\`, not a session ID.
Use the \`background_task_id\` / \`Background Task ID\` from the task launch output or completion notification.
To inspect this session directly, use \`session_read(session_id="${taskId}")\`, \`session_info\`, or \`session_search\`.`
}
export function createBackgroundOutput(manager: BackgroundOutputManager, client: BackgroundOutputClient): ToolDefinition {
return tool({
description: BACKGROUND_OUTPUT_DESCRIPTION,
@@ -60,7 +76,7 @@ export function createBackgroundOutput(manager: BackgroundOutputManager, client:
const ctx = toolContext as ToolContextWithMetadata
const task = manager.getTask(args.task_id)
if (!task) {
return `Task not found: ${args.task_id}`
return formatTaskNotFoundMessage(args.task_id)
}
const meta = {
@@ -45,6 +45,9 @@ describe("executeBackgroundContinuation - subagent metadata", () => {
expect(result).toContain("<task_metadata>")
expect(result).toContain("subagent: oracle")
expect(result).toContain("session_id: ses_resumed_123")
expect(result).toContain("background_task_id: bg_task_001")
expect(result).not.toContain("task_id: ses_resumed_123")
expect(result).toContain("Background Task ID: bg_task_001")
})
test("omits subagent from task_metadata when task agent is undefined", async () => {
@@ -60,7 +60,7 @@ export async function executeBackgroundContinuation(
return `Background task continued.
Task ID: ${backgroundTaskId}
Background Task ID: ${backgroundTaskId}
Description: ${task.description}
Agent: ${task.agent}
Status: ${task.status}
@@ -72,7 +72,6 @@ Do NOT call background_output now. Wait for <system-reminder> notification first
${buildTaskMetadataBlock({
sessionId,
taskId: sessionId,
backgroundTaskId,
agent: task.agent,
category: task.category,
@@ -104,7 +104,7 @@ describeFn("executeBackgroundTask output/session metadata compatibility", () =>
//#then - output and metadata should include canonical session linkage
expectFn(result).toContain("<task_metadata>")
expectFn(result).toContain("session_id: ses_sub_123")
expectFn(result).toContain("task_id: ses_sub_123")
expectFn(result).not.toContain("task_id: ses_sub_123")
expectFn(result).toContain("background_task_id: bg_resolved")
expectFn(result).toContain("subagent: explore")
expectFn(result).toContain("Background Task ID: bg_resolved")
@@ -114,6 +114,49 @@ describeFn("executeBackgroundTask output/session metadata compatibility", () =>
expectFn(metadataCalls[0].metadata.backgroundTaskId).toBe("bg_resolved")
})
testFn("keeps continuation taskId out of visible background metadata", async () => {
//#given - launched background task with both a background id and session id
const metadataCalls: Array<{ metadata: Record<string, unknown> }> = []
const manager = {
launch: async () => ({
id: "bg_visible_contract",
sessionId: "ses_visible_contract",
description: "Visible contract",
agent: "explore",
status: "running",
}),
getTask: () => ({ sessionId: "ses_visible_contract" }),
}
const result = await executeBackgroundTask(
{
description: "Visible contract",
prompt: "check",
run_in_background: true,
load_skills: [],
},
{
sessionID: "ses_parent",
callID: "call_visible_contract",
metadata: async (value: { metadata: Record<string, unknown> }) => metadataCalls.push(value),
abort: new AbortController().signal,
},
{ manager },
{ sessionID: "ses_parent", messageID: "msg_visible_contract" },
"explore",
undefined,
undefined,
undefined,
)
//#then - machine metadata keeps OpenCode compatibility, visible text avoids the overloaded task_id label
expectFn(result).toContain("session_id: ses_visible_contract")
expectFn(result).toContain("background_task_id: bg_visible_contract")
expectFn(result).not.toContain("task_id: ses_visible_contract")
expectFn(metadataCalls[0].metadata.taskId).toBe("ses_visible_contract")
expectFn(metadataCalls[0].metadata.backgroundTaskId).toBe("bg_visible_contract")
})
testFn("captures late-resolved session id and emits synced metadata", async () => {
//#given - background task session id appears after launch via manager polling
const metadataCalls: any[] = []
@@ -155,7 +198,7 @@ describeFn("executeBackgroundTask output/session metadata compatibility", () =>
//#then - late session id still propagates to task metadata contract
expectFn(result).toContain("session_id: ses_late_123")
expectFn(result).toContain("task_id: ses_late_123")
expectFn(result).not.toContain("task_id: ses_late_123")
expectFn(result).toContain("background_task_id: bg_late")
expectFn(metadataCalls).toHaveLength(1)
expectFn(metadataCalls[0].metadata.sessionId).toBe("ses_late_123")
@@ -192,7 +192,6 @@ export async function executeBackgroundTask(
const taskMetadataBlock = sessionId
? `\n\n${buildTaskMetadataBlock({
sessionId,
taskId: sessionId,
backgroundTaskId: task.id,
agent: task.agent,
category: args.category,
@@ -89,7 +89,6 @@ export async function executeUnstableAgentTask(
const taskMetadataBlock = buildTaskMetadataBlock({
sessionId: sessionID,
taskId: sessionID,
backgroundTaskId: task.id,
agent: agentToUse,
category: args.category,