bdf6e4195a
fix(agent): narrow ULW auto-start + strip ZWSP on background path + fix auto-update identity
524 lines
14 KiB
TypeScript
524 lines
14 KiB
TypeScript
import { describe, test, expect, mock, afterEach } from "bun:test"
|
|
import { createTask, startTask } from "./spawner"
|
|
import type { BackgroundTask } from "./types"
|
|
import {
|
|
clearSessionPromptParams,
|
|
getSessionPromptParams,
|
|
} from "../../shared/session-prompt-params-state"
|
|
|
|
describe("background-agent spawner agent-not-found fallback", () => {
|
|
afterEach(() => {
|
|
clearSessionPromptParams("session-fallback")
|
|
})
|
|
|
|
test("retries with 'general' agent when promptAsync fails with Agent not found", async () => {
|
|
//#given
|
|
const promptCalls: any[] = []
|
|
let callCount = 0
|
|
|
|
const client = {
|
|
session: {
|
|
get: async () => ({ data: { directory: "/tmp/test" } }),
|
|
create: async () => ({ data: { id: "session-fallback" } }),
|
|
promptAsync: async (args: any) => {
|
|
callCount++
|
|
promptCalls.push({ body: { ...args.body }, path: { ...args.path } })
|
|
if (callCount === 1) {
|
|
throw new Error('Agent not found: "Sisyphus-Junior". Available agents: build, explore, general, plan')
|
|
}
|
|
return { data: {} }
|
|
},
|
|
},
|
|
} as any
|
|
|
|
const onTaskError = mock(() => {})
|
|
|
|
const task = createTask({
|
|
description: "Implement feature",
|
|
prompt: "Please implement the break-even analysis",
|
|
agent: "Sisyphus-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: "/tmp/test",
|
|
concurrencyManager: { release: () => {} },
|
|
tmuxEnabled: false,
|
|
onTaskError,
|
|
}
|
|
|
|
//#when
|
|
await startTask(item as any, ctx as any)
|
|
|
|
// Wait for the fire-and-forget prompt chain to settle
|
|
await new Promise(resolve => setTimeout(resolve, 50))
|
|
|
|
//#then
|
|
// Should have called promptAsync twice: once with original agent, once with fallback
|
|
expect(promptCalls).toHaveLength(2)
|
|
expect(promptCalls[0].body.agent).toBe("Sisyphus-Junior")
|
|
expect(promptCalls[1].body.agent).toBe("general")
|
|
// Original prompt content preserved in fallback
|
|
expect(promptCalls[1].body.parts).toEqual(promptCalls[0].body.parts)
|
|
// Tool restrictions recomputed for fallback agent (general has no restrictions)
|
|
expect(promptCalls[1].body.tools).toEqual({
|
|
task: false,
|
|
call_omo_agent: true,
|
|
question: false,
|
|
})
|
|
// Task agent identity updated to reflect fallback
|
|
expect(task.agent).toBe("general")
|
|
// Task should not have errored
|
|
expect(onTaskError).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test("does not retry for non-agent-not-found errors", async () => {
|
|
//#given
|
|
const promptCalls: any[] = []
|
|
|
|
const client = {
|
|
session: {
|
|
get: async () => ({ data: { directory: "/tmp/test" } }),
|
|
create: async () => ({ data: { id: "session-fallback" } }),
|
|
promptAsync: async (args: any) => {
|
|
promptCalls.push(args)
|
|
throw new Error("Connection timeout")
|
|
},
|
|
},
|
|
} as any
|
|
|
|
const onTaskError = mock(() => {})
|
|
|
|
const task = createTask({
|
|
description: "Implement feature",
|
|
prompt: "Do work",
|
|
agent: "Sisyphus-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,
|
|
},
|
|
}
|
|
|
|
const ctx = {
|
|
client,
|
|
directory: "/tmp/test",
|
|
concurrencyManager: { release: () => {} },
|
|
tmuxEnabled: false,
|
|
onTaskError,
|
|
}
|
|
|
|
//#when
|
|
await startTask(item as any, ctx as any)
|
|
await new Promise(resolve => setTimeout(resolve, 50))
|
|
|
|
//#then
|
|
// Only one attempt — no retry for non-agent errors
|
|
expect(promptCalls).toHaveLength(1)
|
|
expect(onTaskError).toHaveBeenCalled()
|
|
})
|
|
|
|
test("calls onTaskError if fallback agent also fails", async () => {
|
|
//#given
|
|
let callCount = 0
|
|
const client = {
|
|
session: {
|
|
get: async () => ({ data: { directory: "/tmp/test" } }),
|
|
create: async () => ({ data: { id: "session-fallback" } }),
|
|
promptAsync: async () => {
|
|
callCount++
|
|
throw new Error('Agent not found: "Sisyphus-Junior". Available agents: build, explore, general, plan')
|
|
},
|
|
},
|
|
} as any
|
|
|
|
const onTaskError = mock(() => {})
|
|
|
|
const task = createTask({
|
|
description: "Implement feature",
|
|
prompt: "Do work",
|
|
agent: "Sisyphus-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,
|
|
},
|
|
}
|
|
|
|
const ctx = {
|
|
client,
|
|
directory: "/tmp/test",
|
|
concurrencyManager: { release: () => {} },
|
|
tmuxEnabled: false,
|
|
onTaskError,
|
|
}
|
|
|
|
//#when
|
|
await startTask(item as any, ctx as any)
|
|
await new Promise(resolve => setTimeout(resolve, 50))
|
|
|
|
//#then
|
|
// Verify retry was attempted (2 calls: original + fallback)
|
|
expect(callCount).toBe(2)
|
|
expect(onTaskError).toHaveBeenCalled()
|
|
})
|
|
|
|
test("retries on agent.name/undefined error variant", async () => {
|
|
//#given
|
|
const promptCalls: any[] = []
|
|
let callCount = 0
|
|
|
|
const client = {
|
|
session: {
|
|
get: async () => ({ data: { directory: "/tmp/test" } }),
|
|
create: async () => ({ data: { id: "session-fallback" } }),
|
|
promptAsync: async (args: any) => {
|
|
callCount++
|
|
promptCalls.push({ body: { ...args.body } })
|
|
if (callCount === 1) {
|
|
throw new Error("Cannot read properties of undefined (reading 'agent.name')")
|
|
}
|
|
return { data: {} }
|
|
},
|
|
},
|
|
} as any
|
|
|
|
const onTaskError = mock(() => {})
|
|
|
|
const task = createTask({
|
|
description: "Test task",
|
|
prompt: "Do work",
|
|
agent: "Sisyphus-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: "/tmp/test",
|
|
concurrencyManager: { release: () => {} },
|
|
tmuxEnabled: false,
|
|
onTaskError,
|
|
}
|
|
|
|
//#when
|
|
await startTask(item as any, ctx as any)
|
|
await new Promise(resolve => setTimeout(resolve, 50))
|
|
|
|
//#then
|
|
expect(promptCalls).toHaveLength(2)
|
|
expect(promptCalls[0].body.agent).toBe("Sisyphus-Junior")
|
|
expect(promptCalls[1].body.agent).toBe("general")
|
|
expect(onTaskError).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test("detects agent error from plain object with message field", async () => {
|
|
//#given
|
|
const promptCalls: any[] = []
|
|
let callCount = 0
|
|
|
|
const client = {
|
|
session: {
|
|
get: async () => ({ data: { directory: "/tmp/test" } }),
|
|
create: async () => ({ data: { id: "session-fallback" } }),
|
|
promptAsync: async (args: any) => {
|
|
callCount++
|
|
promptCalls.push({ body: { ...args.body } })
|
|
if (callCount === 1) {
|
|
throw { message: 'Agent not found: "Custom-Agent"', name: "UnknownError" }
|
|
}
|
|
return { data: {} }
|
|
},
|
|
},
|
|
} as any
|
|
|
|
const onTaskError = mock(() => {})
|
|
|
|
const task = createTask({
|
|
description: "Test task",
|
|
prompt: "Do work",
|
|
agent: "Custom-Agent",
|
|
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: "/tmp/test",
|
|
concurrencyManager: { release: () => {} },
|
|
tmuxEnabled: false,
|
|
onTaskError,
|
|
}
|
|
|
|
//#when
|
|
await startTask(item as any, ctx as any)
|
|
await new Promise(resolve => setTimeout(resolve, 50))
|
|
|
|
//#then
|
|
expect(promptCalls).toHaveLength(2)
|
|
expect(promptCalls[1].body.agent).toBe("general")
|
|
expect(onTaskError).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe("background-agent spawner fallback model promotion", () => {
|
|
afterEach(() => {
|
|
clearSessionPromptParams("session-123")
|
|
})
|
|
|
|
test("passes promoted fallback model settings through supported prompt channels", async () => {
|
|
//#given
|
|
let promptArgs: any
|
|
const client = {
|
|
session: {
|
|
get: mock(async () => ({ data: { directory: "/tmp/test" } })),
|
|
create: mock(async () => ({ data: { id: "session-123" } })),
|
|
promptAsync: mock(async (input: any) => {
|
|
promptArgs = input
|
|
return { data: {} }
|
|
}),
|
|
},
|
|
} as any
|
|
|
|
const concurrencyManager = {
|
|
release: mock(() => {}),
|
|
} as any
|
|
|
|
const onTaskError = mock(() => {})
|
|
|
|
const task: BackgroundTask = {
|
|
id: "bg_test123",
|
|
status: "pending",
|
|
queuedAt: new Date(),
|
|
description: "Test task",
|
|
prompt: "Do the thing",
|
|
agent: "oracle",
|
|
parentSessionID: "parent-1",
|
|
parentMessageID: "message-1",
|
|
model: {
|
|
providerID: "openai",
|
|
modelID: "gpt-5.4",
|
|
variant: "low",
|
|
reasoningEffort: "high",
|
|
temperature: 0.4,
|
|
top_p: 0.7,
|
|
maxTokens: 4096,
|
|
thinking: { type: "disabled" },
|
|
},
|
|
}
|
|
|
|
const input = {
|
|
description: "Test task",
|
|
prompt: "Do the thing",
|
|
agent: "oracle",
|
|
parentSessionID: "parent-1",
|
|
parentMessageID: "message-1",
|
|
model: task.model,
|
|
}
|
|
|
|
//#when
|
|
await startTask(
|
|
{ task, input },
|
|
{
|
|
client,
|
|
directory: "/tmp/test",
|
|
concurrencyManager,
|
|
tmuxEnabled: false,
|
|
onTaskError,
|
|
},
|
|
)
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
|
|
//#then
|
|
expect(promptArgs.body.model).toEqual({
|
|
providerID: "openai",
|
|
modelID: "gpt-5.4",
|
|
})
|
|
expect(promptArgs.body.variant).toBe("low")
|
|
expect(promptArgs.body.options).toBeUndefined()
|
|
expect(getSessionPromptParams("session-123")).toEqual({
|
|
temperature: 0.4,
|
|
topP: 0.7,
|
|
maxOutputTokens: 4096,
|
|
options: {
|
|
reasoningEffort: "high",
|
|
thinking: { type: "disabled" },
|
|
},
|
|
})
|
|
})
|
|
|
|
test("keeps agent when explicit model is configured", async () => {
|
|
//#given
|
|
const promptCalls: any[] = []
|
|
|
|
const client = {
|
|
session: {
|
|
get: async () => ({ data: { directory: "/parent/dir" } }),
|
|
create: async () => ({ data: { id: "ses_child" } }),
|
|
promptAsync: async (args?: any) => {
|
|
promptCalls.push(args)
|
|
return {}
|
|
},
|
|
},
|
|
}
|
|
|
|
const task = createTask({
|
|
description: "Test task",
|
|
prompt: "Do work",
|
|
agent: "sisyphus-junior",
|
|
parentSessionID: "ses_parent",
|
|
parentMessageID: "msg_parent",
|
|
model: { providerID: "openai", modelID: "gpt-5.4", variant: "medium" },
|
|
})
|
|
|
|
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)
|
|
|
|
//#then
|
|
expect(promptCalls).toHaveLength(1)
|
|
expect(promptCalls[0]?.body?.agent).toBe("sisyphus-junior")
|
|
expect(promptCalls[0]?.body?.model).toEqual({
|
|
providerID: "openai",
|
|
modelID: "gpt-5.4",
|
|
})
|
|
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")
|
|
})
|
|
})
|