526 lines
16 KiB
TypeScript
526 lines
16 KiB
TypeScript
const bunTest = require("bun:test")
|
|
const describeFn = bunTest.describe
|
|
const testFn = bunTest.test
|
|
const expectFn = bunTest.expect
|
|
const beforeEachFn = bunTest.beforeEach
|
|
const afterEachFn = bunTest.afterEach
|
|
|
|
const { executeBackgroundTask } = require("./background-task")
|
|
const { __setTimingConfig, __resetTimingConfig } = require("./timing")
|
|
const { SessionCategoryRegistry } = require("../../shared/session-category-registry")
|
|
|
|
describeFn("executeBackgroundTask output/session metadata compatibility", () => {
|
|
beforeEachFn(() => {
|
|
//#given - reduce waiting to keep tests fast
|
|
__setTimingConfig({
|
|
WAIT_FOR_SESSION_INTERVAL_MS: 1,
|
|
WAIT_FOR_SESSION_TIMEOUT_MS: 50,
|
|
})
|
|
})
|
|
|
|
afterEachFn(() => {
|
|
__resetTimingConfig()
|
|
SessionCategoryRegistry.clear()
|
|
})
|
|
|
|
testFn("does not emit synthetic pending session metadata when session id is unresolved", async () => {
|
|
//#given - launched task without resolved subagent session id
|
|
const metadataCalls: any[] = []
|
|
const manager = {
|
|
launch: async () => ({
|
|
id: "bg_unresolved",
|
|
sessionID: undefined,
|
|
description: "Unresolved session",
|
|
agent: "explore",
|
|
status: "running",
|
|
}),
|
|
getTask: () => undefined,
|
|
}
|
|
|
|
const result = await executeBackgroundTask(
|
|
{
|
|
description: "Unresolved session",
|
|
prompt: "check",
|
|
run_in_background: true,
|
|
load_skills: [],
|
|
},
|
|
{
|
|
sessionID: "ses_parent",
|
|
callID: "call_1",
|
|
metadata: async (value: any) => metadataCalls.push(value),
|
|
abort: new AbortController().signal,
|
|
},
|
|
{ manager },
|
|
{ sessionID: "ses_parent", messageID: "msg_1" },
|
|
"explore",
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
)
|
|
|
|
//#then - output and metadata should avoid fake session markers
|
|
expectFn(result).not.toContain("<task_metadata>")
|
|
expectFn(result).not.toContain("session_id: undefined")
|
|
expectFn(result).not.toContain("session_id: pending")
|
|
expectFn(metadataCalls).toHaveLength(1)
|
|
expectFn("sessionId" in metadataCalls[0].metadata).toBe(false)
|
|
})
|
|
|
|
testFn("emits task metadata session_id when real session id is available", async () => {
|
|
//#given - launched task with resolved subagent session id
|
|
const metadataCalls: any[] = []
|
|
const manager = {
|
|
launch: async () => ({
|
|
id: "bg_resolved",
|
|
sessionID: "ses_sub_123",
|
|
description: "Resolved session",
|
|
agent: "explore",
|
|
status: "running",
|
|
}),
|
|
getTask: () => ({ sessionID: "ses_sub_123" }),
|
|
}
|
|
|
|
const result = await executeBackgroundTask(
|
|
{
|
|
description: "Resolved session",
|
|
prompt: "check",
|
|
run_in_background: true,
|
|
load_skills: [],
|
|
},
|
|
{
|
|
sessionID: "ses_parent",
|
|
callID: "call_2",
|
|
metadata: async (value: any) => metadataCalls.push(value),
|
|
abort: new AbortController().signal,
|
|
},
|
|
{ manager },
|
|
{ sessionID: "ses_parent", messageID: "msg_2" },
|
|
"explore",
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
)
|
|
|
|
//#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).toContain("background_task_id: bg_resolved")
|
|
expectFn(result).toContain("subagent: explore")
|
|
expectFn(result).toContain("Background Task ID: bg_resolved")
|
|
expectFn(metadataCalls).toHaveLength(1)
|
|
expectFn(metadataCalls[0].metadata.sessionId).toBe("ses_sub_123")
|
|
expectFn(metadataCalls[0].metadata.taskId).toBe("ses_sub_123")
|
|
expectFn(metadataCalls[0].metadata.backgroundTaskId).toBe("bg_resolved")
|
|
})
|
|
|
|
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[] = []
|
|
let reads = 0
|
|
const manager = {
|
|
launch: async () => ({
|
|
id: "bg_late",
|
|
sessionID: undefined,
|
|
description: "Late session",
|
|
agent: "explore",
|
|
status: "running",
|
|
}),
|
|
getTask: () => {
|
|
reads += 1
|
|
return reads >= 2 ? { sessionID: "ses_late_123" } : undefined
|
|
},
|
|
}
|
|
|
|
const result = await executeBackgroundTask(
|
|
{
|
|
description: "Late session",
|
|
prompt: "check",
|
|
run_in_background: true,
|
|
load_skills: [],
|
|
},
|
|
{
|
|
sessionID: "ses_parent",
|
|
callID: "call_3",
|
|
metadata: async (value: any) => metadataCalls.push(value),
|
|
abort: new AbortController().signal,
|
|
},
|
|
{ manager },
|
|
{ sessionID: "ses_parent", messageID: "msg_3" },
|
|
"explore",
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
)
|
|
|
|
//#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).toContain("background_task_id: bg_late")
|
|
expectFn(metadataCalls).toHaveLength(1)
|
|
expectFn(metadataCalls[0].metadata.sessionId).toBe("ses_late_123")
|
|
expectFn(metadataCalls[0].metadata.taskId).toBe("ses_late_123")
|
|
expectFn(metadataCalls[0].metadata.backgroundTaskId).toBe("bg_late")
|
|
})
|
|
|
|
testFn("passes question-deny session permission when launching delegate task", async () => {
|
|
//#given - delegate task background launch should deny question at session creation time
|
|
const launchCalls: any[] = []
|
|
const manager = {
|
|
launch: async (input: any) => {
|
|
launchCalls.push(input)
|
|
return {
|
|
id: "bg_permission",
|
|
sessionID: "ses_permission_123",
|
|
description: "Permission session",
|
|
agent: "explore",
|
|
status: "running",
|
|
}
|
|
},
|
|
getTask: () => ({ sessionID: "ses_permission_123" }),
|
|
}
|
|
|
|
//#when
|
|
await executeBackgroundTask(
|
|
{
|
|
description: "Permission session",
|
|
prompt: "check",
|
|
run_in_background: true,
|
|
load_skills: [],
|
|
},
|
|
{
|
|
sessionID: "ses_parent",
|
|
callID: "call_4",
|
|
metadata: async () => {},
|
|
abort: new AbortController().signal,
|
|
},
|
|
{ manager },
|
|
{ sessionID: "ses_parent", messageID: "msg_4" },
|
|
"explore",
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
)
|
|
|
|
//#then
|
|
expectFn(launchCalls).toHaveLength(1)
|
|
expectFn(launchCalls[0].sessionPermission).toEqual([
|
|
{ permission: "question", action: "deny", pattern: "*" },
|
|
])
|
|
})
|
|
|
|
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[] = []
|
|
const abortController = new AbortController()
|
|
const manager = {
|
|
launch: async () => ({
|
|
id: "bg_abort_after_launch",
|
|
sessionID: undefined,
|
|
description: "Abort after launch",
|
|
agent: "explore",
|
|
status: "pending",
|
|
}),
|
|
getTask: () => {
|
|
abortController.abort()
|
|
return { sessionID: undefined, status: "pending" }
|
|
},
|
|
}
|
|
|
|
//#when
|
|
const result = await executeBackgroundTask(
|
|
{
|
|
description: "Abort after launch",
|
|
prompt: "check",
|
|
run_in_background: true,
|
|
load_skills: [],
|
|
},
|
|
{
|
|
sessionID: "ses_parent",
|
|
callID: "call_abort_after_launch",
|
|
metadata: async (value: any) => metadataCalls.push(value),
|
|
abort: abortController.signal,
|
|
},
|
|
{ manager },
|
|
{ sessionID: "ses_parent", messageID: "msg_abort_after_launch" },
|
|
"explore",
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
)
|
|
|
|
//#then - background launch should still succeed without fake abort failure
|
|
expectFn(result).toContain("Background task launched")
|
|
expectFn(result).toContain("Background Task ID: bg_abort_after_launch")
|
|
expectFn(result).not.toContain("Task aborted while waiting for session to start")
|
|
expectFn(metadataCalls).toHaveLength(1)
|
|
expectFn("sessionId" in metadataCalls[0].metadata).toBe(false)
|
|
})
|
|
|
|
testFn("registers late session category even when parent aborts before session id resolves", async () => {
|
|
//#given - session wiring should continue after returning early on parent abort
|
|
const abortController = new AbortController()
|
|
abortController.abort()
|
|
let reads = 0
|
|
const manager = {
|
|
launch: async () => ({
|
|
id: "bg_abort_category",
|
|
sessionID: undefined,
|
|
description: "Abort category",
|
|
agent: "explore",
|
|
status: "pending",
|
|
}),
|
|
getTask: () => {
|
|
reads += 1
|
|
return reads >= 2
|
|
? { sessionID: "ses_abort_category", status: "running" }
|
|
: { sessionID: undefined, status: "pending" }
|
|
},
|
|
}
|
|
|
|
//#when
|
|
const result = await executeBackgroundTask(
|
|
{
|
|
description: "Abort category",
|
|
prompt: "check",
|
|
run_in_background: true,
|
|
load_skills: [],
|
|
category: "quick",
|
|
},
|
|
{
|
|
sessionID: "ses_parent",
|
|
callID: "call_abort_category",
|
|
metadata: async () => {},
|
|
abort: abortController.signal,
|
|
},
|
|
{ manager },
|
|
{ sessionID: "ses_parent", messageID: "msg_abort_category" },
|
|
"explore",
|
|
undefined,
|
|
undefined,
|
|
[{ providers: ["openai"], model: "gpt-5.4" }],
|
|
)
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 5))
|
|
|
|
//#then - late session setup should still register category for runtime fallback
|
|
expectFn(result).toContain("Background task launched")
|
|
expectFn(SessionCategoryRegistry.get("ses_abort_category")).toBe("quick")
|
|
})
|
|
|
|
testFn("prefers child terminal status over parent abort while waiting for session id", async () => {
|
|
//#given - failed child launch should not be misreported as a successful background launch
|
|
const abortController = new AbortController()
|
|
abortController.abort()
|
|
const manager = {
|
|
launch: async () => ({
|
|
id: "bg_abort_terminal",
|
|
sessionID: undefined,
|
|
description: "Abort terminal",
|
|
agent: "explore",
|
|
status: "pending",
|
|
}),
|
|
getTask: () => ({ sessionID: undefined, status: "interrupt" }),
|
|
}
|
|
|
|
//#when
|
|
const result = await executeBackgroundTask(
|
|
{
|
|
description: "Abort terminal",
|
|
prompt: "check",
|
|
run_in_background: true,
|
|
load_skills: [],
|
|
},
|
|
{
|
|
sessionID: "ses_parent",
|
|
callID: "call_abort_terminal",
|
|
metadata: async () => {},
|
|
abort: abortController.signal,
|
|
},
|
|
{ manager },
|
|
{ sessionID: "ses_parent", messageID: "msg_abort_terminal" },
|
|
"explore",
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
)
|
|
|
|
//#then - terminal child status should win over abort and surface the failure
|
|
expectFn(result).toContain("Task failed to start")
|
|
expectFn(result).toContain("interrupt")
|
|
})
|
|
|
|
testFn("reports failure when manager marks task as error during session startup", async () => {
|
|
//#given - session created but startTask throws before prompt is sent
|
|
const metadataCalls: any[] = []
|
|
let reads = 0
|
|
const manager = {
|
|
launch: async () => ({
|
|
id: "bg_crash_before_prompt",
|
|
sessionID: undefined,
|
|
description: "Crash before prompt",
|
|
agent: "explore",
|
|
status: "pending",
|
|
}),
|
|
getTask: () => {
|
|
reads += 1
|
|
if (reads >= 2) {
|
|
return { sessionID: "ses_orphan", status: "error", error: "crash between session creation and prompt send" }
|
|
}
|
|
return { sessionID: undefined, status: "pending" }
|
|
},
|
|
}
|
|
|
|
//#when
|
|
const result = await executeBackgroundTask(
|
|
{
|
|
description: "Crash before prompt",
|
|
prompt: "check",
|
|
run_in_background: true,
|
|
load_skills: [],
|
|
},
|
|
{
|
|
sessionID: "ses_parent",
|
|
callID: "call_crash",
|
|
metadata: async (value: any) => metadataCalls.push(value),
|
|
abort: new AbortController().signal,
|
|
},
|
|
{ manager },
|
|
{ sessionID: "ses_parent", messageID: "msg_crash" },
|
|
"explore",
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
)
|
|
|
|
//#then - polling loop should detect terminal status and report failure
|
|
expectFn(result).toContain("Task failed to start")
|
|
expectFn(result).toContain("error")
|
|
})
|
|
|
|
testFn("keeps sibling background launch alive when two tasks start concurrently", async () => {
|
|
//#given - one aborted parent call should not interrupt a sibling launch from the same parent session
|
|
const firstAbortController = new AbortController()
|
|
const secondAbortController = new AbortController()
|
|
const states = new Map([
|
|
["bg_first", { reads: 0, abortOnFirstRead: true, sessionID: "ses_first" }],
|
|
["bg_second", { reads: 0, abortOnFirstRead: false, sessionID: "ses_second" }],
|
|
])
|
|
let launchCount = 0
|
|
const manager = {
|
|
launch: async () => {
|
|
launchCount += 1
|
|
return launchCount === 1
|
|
? { id: "bg_first", sessionID: undefined, description: "First", agent: "explore", status: "pending" }
|
|
: { id: "bg_second", sessionID: undefined, description: "Second", agent: "explore", status: "pending" }
|
|
},
|
|
getTask: (taskID: string) => {
|
|
const state = states.get(taskID)
|
|
if (!state) return undefined
|
|
state.reads += 1
|
|
if (state.abortOnFirstRead && state.reads === 1) {
|
|
firstAbortController.abort()
|
|
}
|
|
return state.reads >= 2
|
|
? { sessionID: state.sessionID, status: "running" }
|
|
: { sessionID: undefined, status: "pending" }
|
|
},
|
|
}
|
|
|
|
//#when
|
|
const [firstResult, secondResult] = await Promise.all([
|
|
executeBackgroundTask(
|
|
{
|
|
description: "First",
|
|
prompt: "check",
|
|
run_in_background: true,
|
|
load_skills: [],
|
|
},
|
|
{
|
|
sessionID: "ses_parent",
|
|
callID: "call_first",
|
|
metadata: async () => {},
|
|
abort: firstAbortController.signal,
|
|
},
|
|
{ manager },
|
|
{ sessionID: "ses_parent", messageID: "msg_first" },
|
|
"explore",
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
),
|
|
executeBackgroundTask(
|
|
{
|
|
description: "Second",
|
|
prompt: "check",
|
|
run_in_background: true,
|
|
load_skills: [],
|
|
},
|
|
{
|
|
sessionID: "ses_parent",
|
|
callID: "call_second",
|
|
metadata: async () => {},
|
|
abort: secondAbortController.signal,
|
|
},
|
|
{ manager },
|
|
{ sessionID: "ses_parent", messageID: "msg_second" },
|
|
"explore",
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
),
|
|
])
|
|
|
|
//#then - both tasks still launch and the sibling is not reported as interrupted
|
|
expectFn(firstResult).toContain("Background task launched")
|
|
expectFn(firstResult).not.toContain("Task failed to start")
|
|
expectFn(secondResult).toContain("Background task launched")
|
|
expectFn(secondResult).toContain("session_id: ses_second")
|
|
expectFn(secondResult).not.toContain("interrupt")
|
|
})
|
|
})
|