oh-my-opencode/src/tools/delegate-task/unstable-agent-task.test.ts
YeonGyu-Kim 0ef682965f fix: detect interrupted/error/cancelled status in unstable-agent-task polling loop
The polling loop in executeUnstableAgentTask only checked session status
and message stability, never checking if the background task itself had
been interrupted. This caused the tool call to hang until MAX_POLL_TIME_MS
(10 minutes) when a task was interrupted by prompt errors.

Add manager.getTask() check at each poll iteration to break immediately
on terminal statuses (interrupt, error, cancelled), returning a clear
failure message instead of hanging.
2026-02-16 15:56:52 +09:00

225 lines
6.5 KiB
TypeScript

const { describe, test, expect, beforeEach, afterEach, mock } = require("bun:test")
describe("executeUnstableAgentTask - interrupt detection", () => {
beforeEach(() => {
//#given - configure fast timing for all tests
const { __setTimingConfig } = require("./timing")
__setTimingConfig({
POLL_INTERVAL_MS: 10,
MIN_STABILITY_TIME_MS: 0,
STABILITY_POLLS_REQUIRED: 1,
MAX_POLL_TIME_MS: 500,
WAIT_FOR_SESSION_TIMEOUT_MS: 100,
WAIT_FOR_SESSION_INTERVAL_MS: 10,
})
})
afterEach(() => {
//#given - reset timing after each test
const { __resetTimingConfig } = require("./timing")
__resetTimingConfig()
mock.restore()
})
test("should return error immediately when background task becomes interrupted during polling", async () => {
//#given - a background task that gets interrupted on first poll check
const taskState = {
id: "bg_test_interrupt",
sessionID: "ses_test_interrupt",
status: "interrupt" as string,
description: "test interrupted task",
prompt: "test prompt",
agent: "sisyphus-junior",
error: "Agent not found" as string | undefined,
}
const launchState = { ...taskState, status: "running" as string, error: undefined as string | undefined }
const mockManager = {
launch: async () => launchState,
getTask: () => taskState,
}
const mockClient = {
session: {
status: async () => ({ data: { [taskState.sessionID!]: { type: "idle" } } }),
messages: async () => ({ data: [] }),
},
}
const { executeUnstableAgentTask } = require("./unstable-agent-task")
const args = {
prompt: "test prompt",
description: "test task",
category: "test",
load_skills: [],
run_in_background: false,
}
const mockCtx = {
sessionID: "parent-session",
callID: "call-123",
metadata: () => {},
}
const mockExecutorCtx = {
manager: mockManager,
client: mockClient,
directory: "/tmp",
}
const parentContext = {
sessionID: "parent-session",
messageID: "msg-123",
}
//#when - executeUnstableAgentTask encounters an interrupted task
const startTime = Date.now()
const result = await executeUnstableAgentTask(
args, mockCtx, mockExecutorCtx, parentContext,
"test-agent", undefined, undefined, "test-model"
)
const elapsed = Date.now() - startTime
//#then - should return quickly with interrupt error, not hang until MAX_POLL_TIME_MS
expect(result).toContain("interrupt")
expect(result.toLowerCase()).toContain("agent not found")
expect(elapsed).toBeLessThan(400)
})
test("should return error immediately when background task becomes errored during polling", async () => {
//#given - a background task that is already errored when poll checks
const taskState = {
id: "bg_test_error",
sessionID: "ses_test_error",
status: "error" as string,
description: "test error task",
prompt: "test prompt",
agent: "sisyphus-junior",
error: "Rate limit exceeded" as string | undefined,
}
const launchState = { ...taskState, status: "running" as string, error: undefined as string | undefined }
const mockManager = {
launch: async () => launchState,
getTask: () => taskState,
}
const mockClient = {
session: {
status: async () => ({ data: { [taskState.sessionID!]: { type: "idle" } } }),
messages: async () => ({ data: [] }),
},
}
const { executeUnstableAgentTask } = require("./unstable-agent-task")
const args = {
prompt: "test prompt",
description: "test task",
category: "test",
load_skills: [],
run_in_background: false,
}
const mockCtx = {
sessionID: "parent-session",
callID: "call-123",
metadata: () => {},
}
const mockExecutorCtx = {
manager: mockManager,
client: mockClient,
directory: "/tmp",
}
const parentContext = {
sessionID: "parent-session",
messageID: "msg-123",
}
//#when - executeUnstableAgentTask encounters an errored task
const startTime = Date.now()
const result = await executeUnstableAgentTask(
args, mockCtx, mockExecutorCtx, parentContext,
"test-agent", undefined, undefined, "test-model"
)
const elapsed = Date.now() - startTime
//#then - should return quickly with error, not hang until MAX_POLL_TIME_MS
expect(result).toContain("error")
expect(result.toLowerCase()).toContain("rate limit exceeded")
expect(elapsed).toBeLessThan(400)
})
test("should return error immediately when background task becomes cancelled during polling", async () => {
//#given - a background task that is already cancelled when poll checks
const taskState = {
id: "bg_test_cancel",
sessionID: "ses_test_cancel",
status: "cancelled" as string,
description: "test cancelled task",
prompt: "test prompt",
agent: "sisyphus-junior",
error: "Stale timeout" as string | undefined,
}
const launchState = { ...taskState, status: "running" as string, error: undefined as string | undefined }
const mockManager = {
launch: async () => launchState,
getTask: () => taskState,
}
const mockClient = {
session: {
status: async () => ({ data: { [taskState.sessionID!]: { type: "idle" } } }),
messages: async () => ({ data: [] }),
},
}
const { executeUnstableAgentTask } = require("./unstable-agent-task")
const args = {
prompt: "test prompt",
description: "test task",
category: "test",
load_skills: [],
run_in_background: false,
}
const mockCtx = {
sessionID: "parent-session",
callID: "call-123",
metadata: () => {},
}
const mockExecutorCtx = {
manager: mockManager,
client: mockClient,
directory: "/tmp",
}
const parentContext = {
sessionID: "parent-session",
messageID: "msg-123",
}
//#when - executeUnstableAgentTask encounters a cancelled task
const startTime = Date.now()
const result = await executeUnstableAgentTask(
args, mockCtx, mockExecutorCtx, parentContext,
"test-agent", undefined, undefined, "test-model"
)
const elapsed = Date.now() - startTime
//#then - should return quickly with cancel info, not hang until MAX_POLL_TIME_MS
expect(result).toContain("cancel")
expect(result.toLowerCase()).toContain("stale timeout")
expect(elapsed).toBeLessThan(400)
})
})