diff --git a/src/tools/delegate-task/sync-session-poller.test.ts b/src/tools/delegate-task/sync-session-poller.test.ts index 494ce099c..004fa0cb8 100644 --- a/src/tools/delegate-task/sync-session-poller.test.ts +++ b/src/tools/delegate-task/sync-session-poller.test.ts @@ -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) }) }) diff --git a/src/tools/delegate-task/sync-session-poller.ts b/src/tools/delegate-task/sync-session-poller.ts index 810786223..255cfce3d 100644 --- a/src/tools/delegate-task/sync-session-poller.ts +++ b/src/tools/delegate-task/sync-session-poller.ts @@ -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