fix(delegate-task): ignore stale sync session errors
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -29,7 +29,7 @@ describe("pollSyncSession", () => {
|
||||
|
||||
describe("native finish-based completion", () => {
|
||||
test("returns terminal session error when assistant message contains info.error", async () => {
|
||||
//#given
|
||||
// given: error in assistant message
|
||||
const { pollSyncSession } = require("./sync-session-poller")
|
||||
|
||||
const mockClient = {
|
||||
@@ -52,7 +52,7 @@ describe("pollSyncSession", () => {
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
// when: calling pollSyncSession
|
||||
const result = await pollSyncSession(createMockCtx(), mockClient, {
|
||||
sessionID: "ses_test",
|
||||
agentToUse: "test-agent",
|
||||
@@ -60,13 +60,51 @@ describe("pollSyncSession", () => {
|
||||
taskId: undefined,
|
||||
})
|
||||
|
||||
//#then
|
||||
// then: returns error message
|
||||
expect(result).toBe("Forbidden: Selected provider is forbidden")
|
||||
})
|
||||
|
||||
test("ignores stale prior-turn assistant errors after a new user turn starts", async () => {
|
||||
// given: prior error exists but user sent new message
|
||||
const { pollSyncSession } = require("./sync-session-poller")
|
||||
|
||||
const mockClient = {
|
||||
session: {
|
||||
messages: async () => ({
|
||||
data: [
|
||||
{ info: { id: "msg_001", role: "user", time: { created: 1000 } } },
|
||||
{
|
||||
info: {
|
||||
id: "msg_002",
|
||||
role: "assistant",
|
||||
time: { created: 2000 },
|
||||
error: { data: { message: "Forbidden: Selected provider is forbidden" } },
|
||||
},
|
||||
parts: [],
|
||||
},
|
||||
{ info: { id: "msg_003", role: "user", time: { created: 3000 } } },
|
||||
],
|
||||
}),
|
||||
status: async () => ({ data: { "ses_test": { type: "idle" } } }),
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
|
||||
// when: calling with stale error
|
||||
const result = await pollSyncSession(createMockCtx(), mockClient, {
|
||||
sessionID: "ses_test",
|
||||
agentToUse: "test-agent",
|
||||
toastManager: null,
|
||||
taskId: undefined,
|
||||
anchorMessageCount: 2,
|
||||
}, 50)
|
||||
|
||||
// then: times out (ignores stale error)
|
||||
expect(result).toContain("Poll timeout reached")
|
||||
})
|
||||
|
||||
test("detects completion when assistant message has terminal finish reason", async () => {
|
||||
//#given - session messages with a terminal assistant finish ("end_turn")
|
||||
// and the assistant id > user id (native opencode condition)
|
||||
// given: terminal assistant finish with assistant id > user id
|
||||
const { pollSyncSession } = require("./sync-session-poller")
|
||||
|
||||
const mockClient = {
|
||||
@@ -84,7 +122,7 @@ describe("pollSyncSession", () => {
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
// when: calling pollSyncSession
|
||||
const result = await pollSyncSession(createMockCtx(), mockClient, {
|
||||
sessionID: "ses_test",
|
||||
agentToUse: "test-agent",
|
||||
@@ -92,12 +130,12 @@ describe("pollSyncSession", () => {
|
||||
taskId: undefined,
|
||||
})
|
||||
|
||||
//#then - should return null (success, no error)
|
||||
// then: returns null (success)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test("keeps polling when assistant finish is tool-calls (non-terminal)", async () => {
|
||||
//#given - first poll returns tool-calls finish, second returns end_turn
|
||||
// given: first poll returns tool-calls, second returns end_turn
|
||||
const { pollSyncSession } = require("./sync-session-poller")
|
||||
|
||||
let callCount = 0
|
||||
@@ -135,7 +173,7 @@ describe("pollSyncSession", () => {
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
// when: calling pollSyncSession
|
||||
const result = await pollSyncSession(createMockCtx(), mockClient, {
|
||||
sessionID: "ses_test",
|
||||
agentToUse: "test-agent",
|
||||
@@ -143,13 +181,13 @@ describe("pollSyncSession", () => {
|
||||
taskId: undefined,
|
||||
})
|
||||
|
||||
//#then
|
||||
// then: returns null after polling continues
|
||||
expect(result).toBeNull()
|
||||
expect(callCount).toBeGreaterThan(2)
|
||||
})
|
||||
|
||||
test("keeps polling when finish is 'unknown' (non-terminal)", async () => {
|
||||
//#given
|
||||
// given: first poll returns unknown finish
|
||||
const { pollSyncSession } = require("./sync-session-poller")
|
||||
|
||||
let callCount = 0
|
||||
@@ -187,7 +225,7 @@ describe("pollSyncSession", () => {
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
// when: calling pollSyncSession
|
||||
const result = await pollSyncSession(createMockCtx(), mockClient, {
|
||||
sessionID: "ses_test",
|
||||
agentToUse: "test-agent",
|
||||
@@ -195,13 +233,13 @@ describe("pollSyncSession", () => {
|
||||
taskId: undefined,
|
||||
})
|
||||
|
||||
//#then
|
||||
// then: returns null after polling continues
|
||||
expect(result).toBeNull()
|
||||
expect(callCount).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
test("keeps polling when finish is 'stop' but assistant still has tool-call parts", async () => {
|
||||
//#given
|
||||
// given: finish is stop but tool-call parts exist
|
||||
const { pollSyncSession } = require("./sync-session-poller")
|
||||
|
||||
let callCount = 0
|
||||
@@ -239,7 +277,7 @@ describe("pollSyncSession", () => {
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
// when: calling pollSyncSession
|
||||
const result = await pollSyncSession(createMockCtx(), mockClient, {
|
||||
sessionID: "ses_test",
|
||||
agentToUse: "test-agent",
|
||||
@@ -247,13 +285,13 @@ describe("pollSyncSession", () => {
|
||||
taskId: undefined,
|
||||
})
|
||||
|
||||
//#then
|
||||
// then: returns null after polling continues
|
||||
expect(result).toBeNull()
|
||||
expect(callCount).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
test("does not complete when assistant id < user id (user sent after assistant)", async () => {
|
||||
//#given - assistant finished but user message came after it (agent still processing)
|
||||
// given: assistant finished but user message came after it
|
||||
const { pollSyncSession } = require("./sync-session-poller")
|
||||
|
||||
let callCount = 0
|
||||
@@ -292,7 +330,7 @@ describe("pollSyncSession", () => {
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
// when: calling pollSyncSession
|
||||
const result = await pollSyncSession(createMockCtx(), mockClient, {
|
||||
sessionID: "ses_test",
|
||||
agentToUse: "test-agent",
|
||||
@@ -300,7 +338,7 @@ describe("pollSyncSession", () => {
|
||||
taskId: undefined,
|
||||
})
|
||||
|
||||
//#then
|
||||
// then: returns null after polling continues
|
||||
expect(result).toBeNull()
|
||||
expect(callCount).toBeGreaterThan(1)
|
||||
})
|
||||
@@ -308,7 +346,7 @@ describe("pollSyncSession", () => {
|
||||
|
||||
describe("abort handling", () => {
|
||||
test("#given session completed AND abort fires #then returns completion result not abort", async () => {
|
||||
//#given
|
||||
// given: session completes and abort fires
|
||||
const { pollSyncSession } = require("./sync-session-poller")
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
@@ -336,7 +374,7 @@ describe("pollSyncSession", () => {
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
// when: calling pollSyncSession
|
||||
const result = await pollSyncSession({
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
@@ -350,14 +388,14 @@ describe("pollSyncSession", () => {
|
||||
anchorMessageCount: 1,
|
||||
})
|
||||
|
||||
//#then
|
||||
// then: returns null with no abort
|
||||
expect(result).toBeNull()
|
||||
expect(messageCallCount).toBe(1)
|
||||
expect(abortCount).toBe(0)
|
||||
})
|
||||
|
||||
test("returns abort message when signal is aborted", async () => {
|
||||
//#given
|
||||
// given: abort signal already aborted
|
||||
const { pollSyncSession } = require("./sync-session-poller")
|
||||
let abortCount = 0
|
||||
const mockClient = {
|
||||
@@ -370,7 +408,7 @@ describe("pollSyncSession", () => {
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
// when: calling pollSyncSession with aborted signal
|
||||
const result = await pollSyncSession(createMockCtx(true), mockClient, {
|
||||
sessionID: "ses_abort",
|
||||
agentToUse: "test-agent",
|
||||
@@ -378,7 +416,7 @@ describe("pollSyncSession", () => {
|
||||
taskId: "task_123",
|
||||
})
|
||||
|
||||
//#then
|
||||
// then: returns abort message
|
||||
expect(result).toContain("Task aborted")
|
||||
expect(result).toContain("ses_abort")
|
||||
expect(abortCount).toBe(1)
|
||||
@@ -387,7 +425,7 @@ describe("pollSyncSession", () => {
|
||||
|
||||
describe("timeout handling", () => {
|
||||
test("returns error string on timeout", async () => {
|
||||
//#given - never returns a terminal finish, but timeout is very short
|
||||
// given: no terminal finish and short timeout
|
||||
const { pollSyncSession } = require("./sync-session-poller")
|
||||
|
||||
__setTimingConfig({
|
||||
@@ -412,7 +450,7 @@ describe("pollSyncSession", () => {
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
// when: calling pollSyncSession
|
||||
const result = await pollSyncSession(createMockCtx(), mockClient, {
|
||||
sessionID: "ses_timeout",
|
||||
agentToUse: "test-agent",
|
||||
@@ -420,19 +458,19 @@ describe("pollSyncSession", () => {
|
||||
taskId: undefined,
|
||||
}, 0)
|
||||
|
||||
//#then - timeout returns error string
|
||||
// then: returns timeout error
|
||||
expect(result).toBe("Poll timeout reached after 50ms for session ses_timeout")
|
||||
expect(abortCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("non-idle session status", () => {
|
||||
test("skips message check when session is not idle", async () => {
|
||||
//#given
|
||||
const { pollSyncSession } = require("./sync-session-poller")
|
||||
describe("non-idle session status", () => {
|
||||
test("skips message check when session is not idle", async () => {
|
||||
// given: session is running (not idle)
|
||||
const { pollSyncSession } = require("./sync-session-poller")
|
||||
|
||||
let statusCallCount = 0
|
||||
let messageCallCount = 0
|
||||
let statusCallCount = 0
|
||||
let messageCallCount = 0
|
||||
const mockClient = {
|
||||
session: {
|
||||
messages: async () => {
|
||||
@@ -457,54 +495,54 @@ describe("pollSyncSession", () => {
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = await pollSyncSession(createMockCtx(), mockClient, {
|
||||
sessionID: "ses_busy",
|
||||
agentToUse: "test-agent",
|
||||
toastManager: null,
|
||||
taskId: undefined,
|
||||
})
|
||||
// when: calling pollSyncSession
|
||||
const result = await pollSyncSession(createMockCtx(), mockClient, {
|
||||
sessionID: "ses_busy",
|
||||
agentToUse: "test-agent",
|
||||
toastManager: null,
|
||||
taskId: undefined,
|
||||
})
|
||||
|
||||
//#then - should have waited for idle before checking messages
|
||||
expect(result).toBeNull()
|
||||
expect(statusCallCount).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
})
|
||||
// then: waits for idle before checking messages
|
||||
expect(result).toBeNull()
|
||||
expect(statusCallCount).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isSessionComplete edge cases", () => {
|
||||
test("returns false when messages array is empty", () => {
|
||||
const { isSessionComplete } = require("./sync-session-poller")
|
||||
|
||||
//#given - empty messages array
|
||||
// given: empty messages array
|
||||
const messages: any[] = []
|
||||
|
||||
//#when
|
||||
// when: calling isSessionComplete
|
||||
const result = isSessionComplete(messages)
|
||||
|
||||
//#then - should return false
|
||||
// then: returns false
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false when no assistant message exists", () => {
|
||||
const { isSessionComplete } = require("./sync-session-poller")
|
||||
|
||||
//#given - only user messages, no assistant
|
||||
// given: only user messages, no assistant
|
||||
const messages = [
|
||||
{ info: { id: "msg_001", role: "user", time: { created: 1000 } } },
|
||||
{ info: { id: "msg_002", role: "user", time: { created: 2000 } } },
|
||||
]
|
||||
|
||||
//#when
|
||||
// when: calling isSessionComplete
|
||||
const result = isSessionComplete(messages)
|
||||
|
||||
//#then - should return false
|
||||
// then: returns false
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false when only assistant message exists (no user)", () => {
|
||||
const { isSessionComplete } = require("./sync-session-poller")
|
||||
|
||||
//#given - only assistant message, no user message
|
||||
// given: only assistant message, no user message
|
||||
const messages = [
|
||||
{
|
||||
info: { id: "msg_001", role: "assistant", time: { created: 1000 }, finish: "end_turn" },
|
||||
@@ -512,17 +550,17 @@ describe("pollSyncSession", () => {
|
||||
},
|
||||
]
|
||||
|
||||
//#when
|
||||
// when: calling isSessionComplete
|
||||
const result = isSessionComplete(messages)
|
||||
|
||||
//#then - should return false (no user message to compare IDs)
|
||||
// then: returns false (no user message to compare IDs)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false when assistant message has missing finish field", () => {
|
||||
const { isSessionComplete } = require("./sync-session-poller")
|
||||
|
||||
//#given - assistant message without finish field
|
||||
// given: assistant message without finish field
|
||||
const messages = [
|
||||
{ info: { id: "msg_001", role: "user", time: { created: 1000 } } },
|
||||
{
|
||||
@@ -531,17 +569,17 @@ describe("pollSyncSession", () => {
|
||||
},
|
||||
]
|
||||
|
||||
//#when
|
||||
// when: calling isSessionComplete
|
||||
const result = isSessionComplete(messages)
|
||||
|
||||
//#then - should return false (missing finish)
|
||||
// then: returns false (missing finish)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false when assistant message has missing info.id field", () => {
|
||||
const { isSessionComplete } = require("./sync-session-poller")
|
||||
|
||||
//#given - assistant message without id in info
|
||||
// given: assistant message without id in info
|
||||
const messages = [
|
||||
{ info: { id: "msg_001", role: "user", time: { created: 1000 } } },
|
||||
{
|
||||
@@ -550,17 +588,17 @@ describe("pollSyncSession", () => {
|
||||
},
|
||||
]
|
||||
|
||||
//#when
|
||||
// when: calling isSessionComplete
|
||||
const result = isSessionComplete(messages)
|
||||
|
||||
//#then - should return false (missing assistant id)
|
||||
// then: returns false (missing assistant id)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false when finish is stop but assistant has tool-call parts", () => {
|
||||
const { isSessionComplete } = require("./sync-session-poller")
|
||||
|
||||
//#given - provider marks stop even though tool execution is still pending
|
||||
// given: provider marks stop even though tool execution is pending
|
||||
const messages = [
|
||||
{ info: { id: "msg_001", role: "user", time: { created: 1000 } } },
|
||||
{
|
||||
@@ -569,17 +607,17 @@ describe("pollSyncSession", () => {
|
||||
},
|
||||
]
|
||||
|
||||
//#when
|
||||
// when: calling isSessionComplete
|
||||
const result = isSessionComplete(messages)
|
||||
|
||||
//#then - should return false because tool execution is still pending
|
||||
// then: returns false because tool execution is still pending
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false when finish is end_turn but assistant has tool-call parts", () => {
|
||||
const { isSessionComplete } = require("./sync-session-poller")
|
||||
|
||||
//#given - assistant emitted a terminal finish but still contains pending tool calls
|
||||
// given: assistant emitted terminal finish but contains pending tool calls
|
||||
const messages = [
|
||||
{ info: { id: "msg_001", role: "user", time: { created: 1000 } } },
|
||||
{
|
||||
@@ -588,17 +626,17 @@ describe("pollSyncSession", () => {
|
||||
},
|
||||
]
|
||||
|
||||
//#when
|
||||
// when: calling isSessionComplete
|
||||
const result = isSessionComplete(messages)
|
||||
|
||||
//#then - should return false because tool execution is still pending
|
||||
// then: returns false because tool execution is still pending
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false when user message has missing info.id field", () => {
|
||||
const { isSessionComplete } = require("./sync-session-poller")
|
||||
|
||||
//#given - user message without id in info
|
||||
// given: user message without id in info
|
||||
const messages = [
|
||||
{ info: { role: "user", time: { created: 1000 } } },
|
||||
{
|
||||
@@ -607,10 +645,10 @@ describe("pollSyncSession", () => {
|
||||
},
|
||||
]
|
||||
|
||||
//#when
|
||||
// when: calling isSessionComplete
|
||||
const result = isSessionComplete(messages)
|
||||
|
||||
//#then - should return false (missing user id)
|
||||
// then: returns false (missing user id)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,6 +35,10 @@ async function fetchSessionMessages(
|
||||
|
||||
function getTerminalSessionError(messages: SessionMessage[]): string | null {
|
||||
const lastAssistant = [...messages].reverse().find((msg) => msg.info?.role === "assistant")
|
||||
const lastUser = [...messages].reverse().find((msg) => msg.info?.role === "user")
|
||||
if (lastUser?.info?.id && lastAssistant?.info?.id && lastAssistant.info.id <= lastUser.info.id) {
|
||||
return null
|
||||
}
|
||||
if (!lastAssistant?.info || !("error" in lastAssistant.info)) {
|
||||
return null
|
||||
}
|
||||
@@ -159,7 +163,7 @@ export async function pollSyncSession(
|
||||
break
|
||||
}
|
||||
|
||||
// 计数新出现的 assistant 轮次,用于熔断无限循环
|
||||
// Count new assistant turns to circuit-break infinite loops
|
||||
const lastAssistant = [...messages].reverse().find((m) => m.info?.role === "assistant")
|
||||
if (lastAssistant?.info?.id && lastAssistant.info.id !== lastSeenAssistantId) {
|
||||
lastSeenAssistantId = lastAssistant.info.id
|
||||
|
||||
Reference in New Issue
Block a user