diff --git a/src/plugin/recent-synthetic-idles.test.ts b/src/plugin/recent-synthetic-idles.test.ts index 4a91145ac..c6ba1dacd 100644 --- a/src/plugin/recent-synthetic-idles.test.ts +++ b/src/plugin/recent-synthetic-idles.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect } from "bun:test" import { pruneRecentSyntheticIdles } from "./recent-synthetic-idles" describe("pruneRecentSyntheticIdles", () => { - it("removes entries older than dedup window", () => { + it("removes entries where now - emittedAt >= dedupWindowMs (stale cleanup works)", () => { //#given const recentSyntheticIdles = new Map([ ["ses_old", 1000], @@ -21,4 +21,113 @@ describe("pruneRecentSyntheticIdles", () => { expect(recentSyntheticIdles.has("ses_old")).toBe(false) expect(recentSyntheticIdles.has("ses_new")).toBe(true) }) + + it("preserves entries where now - emittedAt < dedupWindowMs (fresh entries kept)", () => { + //#given + const recentSyntheticIdles = new Map([ + ["ses_fresh_1", 1950], + ["ses_fresh_2", 1980], + ]) + + //#when + pruneRecentSyntheticIdles({ + recentSyntheticIdles, + now: 2000, + dedupWindowMs: 100, + }) + + //#then + expect(recentSyntheticIdles.has("ses_fresh_1")).toBe(true) + expect(recentSyntheticIdles.has("ses_fresh_2")).toBe(true) + expect(recentSyntheticIdles.size).toBe(2) + }) + + it("handles empty Map without crashing (no-op on empty)", () => { + //#given + const recentSyntheticIdles = new Map() + + //#when + pruneRecentSyntheticIdles({ + recentSyntheticIdles, + now: 2000, + dedupWindowMs: 500, + }) + + //#then + expect(recentSyntheticIdles.size).toBe(0) + }) + + it("removes only stale entries in mixed sessions (mixed sessions: only stale removed, fresh kept)", () => { + //#given + const recentSyntheticIdles = new Map([ + ["ses_stale_1", 1000], + ["ses_fresh_1", 1950], + ["ses_stale_2", 1200], + ["ses_fresh_2", 1980], + ]) + + //#when + pruneRecentSyntheticIdles({ + recentSyntheticIdles, + now: 2000, + dedupWindowMs: 500, + }) + + //#then + expect(recentSyntheticIdles.has("ses_stale_1")).toBe(false) + expect(recentSyntheticIdles.has("ses_stale_2")).toBe(false) + expect(recentSyntheticIdles.has("ses_fresh_1")).toBe(true) + expect(recentSyntheticIdles.has("ses_fresh_2")).toBe(true) + expect(recentSyntheticIdles.size).toBe(2) + }) + + it("clears all entries when all are stale (all-stale → Map becomes empty)", () => { + //#given + const recentSyntheticIdles = new Map([ + ["ses_old_1", 500], + ["ses_old_2", 800], + ["ses_old_3", 1200], + ]) + + //#when + pruneRecentSyntheticIdles({ + recentSyntheticIdles, + now: 2000, + dedupWindowMs: 500, + }) + + //#then + expect(recentSyntheticIdles.size).toBe(0) + }) + + it("cleans 100+ entries in single pass (bulk cleanup works)", () => { + //#given + const recentSyntheticIdles = new Map() + // Add 50 stale entries + for (let i = 0; i < 50; i++) { + recentSyntheticIdles.set(`ses_stale_${i}`, 500 + i) + } + // Add 60 fresh entries + for (let i = 0; i < 60; i++) { + recentSyntheticIdles.set(`ses_fresh_${i}`, 1950 + i) + } + + //#when + pruneRecentSyntheticIdles({ + recentSyntheticIdles, + now: 2000, + dedupWindowMs: 500, + }) + + //#then + expect(recentSyntheticIdles.size).toBe(60) + // Verify all stale entries are gone + for (let i = 0; i < 50; i++) { + expect(recentSyntheticIdles.has(`ses_stale_${i}`)).toBe(false) + } + // Verify all fresh entries remain + for (let i = 0; i < 60; i++) { + expect(recentSyntheticIdles.has(`ses_fresh_${i}`)).toBe(true) + } + }) }) diff --git a/src/tools/delegate-task/sync-continuation.test.ts b/src/tools/delegate-task/sync-continuation.test.ts new file mode 100644 index 000000000..fa32715b7 --- /dev/null +++ b/src/tools/delegate-task/sync-continuation.test.ts @@ -0,0 +1,309 @@ +declare const require: (name: string) => any +const { describe, test, expect, beforeEach, afterEach, mock } = require("bun:test") + +describe("executeSyncContinuation - toast cleanup error paths", () => { + let removeTaskCalls: string[] = [] + let addTaskCalls: any[] = [] + + 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: 100, + }) + + //#given - reset call tracking + removeTaskCalls = [] + addTaskCalls = [] + + //#given - mock task-toast-manager module + const mockToastManager = { + addTask: (task: any) => { addTaskCalls.push(task) }, + removeTask: (id: string) => { removeTaskCalls.push(id) }, + } + + const mockGetTaskToastManager = () => mockToastManager + + mock.module("../../features/task-toast-manager/index.ts", () => ({ + getTaskToastManager: mockGetTaskToastManager, + TaskToastManager: class {}, + initTaskToastManager: () => mockToastManager, + })) + }) + + afterEach(() => { + //#given - reset timing after each test + const { __resetTimingConfig } = require("./timing") + __resetTimingConfig() + + mock.restore() + }) + + test("removes toast when fetchSyncResult throws an exception", async () => { + //#given - mock dependencies where messages return error state + const mockClient = { + session: { + messages: async () => ({ + data: [ + { info: { id: "msg_001", role: "user", time: { created: 1000 } } }, + { + info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "end_turn" }, + parts: [{ type: "text", text: "Response" }], + }, + ], + }), + promptAsync: async () => ({}), + status: async () => ({ + data: { ses_test: { type: "idle" } }, + }), + }, + } + + const { executeSyncContinuation } = require("./sync-continuation") + + const mockCtx = { + sessionID: "parent-session", + callID: "call-123", + metadata: () => {}, + } + + const mockExecutorCtx = { + client: mockClient, + } + + const args = { + session_id: "ses_test_12345678", + prompt: "test prompt", + description: "test task", + load_skills: [], + run_in_background: false, + } + + //#when - executeSyncContinuation completes + const result = await executeSyncContinuation(args, mockCtx, mockExecutorCtx) + + //#then - removeTask should have been called exactly once + expect(removeTaskCalls.length).toBe(1) + expect(removeTaskCalls[0]).toBe("resume_sync_ses_test") + }) + + test("removes toast when pollSyncSession throws an exception", async () => { + //#given - mock client with completion issues + const mockClient = { + session: { + messages: async () => ({ + data: [ + { info: { id: "msg_001", role: "user", time: { created: 1000 } } }, + { + info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "end_turn" }, + parts: [{ type: "text", text: "Response" }], + }, + ], + }), + promptAsync: async () => ({}), + status: async () => ({ + data: { ses_test: { type: "idle" } }, + }), + }, + } + + const { executeSyncContinuation } = require("./sync-continuation") + + const mockCtx = { + sessionID: "parent-session", + callID: "call-123", + metadata: () => {}, + } + + const mockExecutorCtx = { + client: mockClient, + } + + const args = { + session_id: "ses_test_12345678", + prompt: "test prompt", + description: "test task", + load_skills: [], + run_in_background: false, + } + + //#when - executeSyncContinuation + const result = await executeSyncContinuation(args, mockCtx, mockExecutorCtx) + + //#then - removeTask should have been called exactly once + expect(removeTaskCalls.length).toBe(1) + expect(removeTaskCalls[0]).toBe("resume_sync_ses_test") + }) + + test("removes toast on successful completion", async () => { + //#given - mock dependencies where everything succeeds with new assistant message + const mockClient = { + session: { + messages: async () => ({ + data: [ + { info: { id: "msg_001", role: "user", time: { created: 1000 } } }, + { + info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "end_turn" }, + parts: [{ type: "text", text: "Response" }], + }, + { info: { id: "msg_003", role: "user", time: { created: 3000 } } }, + { + info: { id: "msg_004", role: "assistant", time: { created: 4000 }, finish: "end_turn" }, + parts: [{ type: "text", text: "New response" }], + }, + ], + }), + promptAsync: async () => ({}), + status: async () => ({ + data: { ses_test: { type: "idle" } }, + }), + }, + } + + const { executeSyncContinuation } = require("./sync-continuation") + + const mockCtx = { + sessionID: "parent-session", + callID: "call-123", + metadata: () => {}, + } + + const mockExecutorCtx = { + client: mockClient, + } + + const args = { + session_id: "ses_test_12345678", + prompt: "test prompt", + description: "test task", + load_skills: [], + run_in_background: false, + } + + //#when - executeSyncContinuation successfully + const result = await executeSyncContinuation(args, mockCtx, mockExecutorCtx) + + //#then - removeTask should have been called exactly once + expect(removeTaskCalls.length).toBe(1) + expect(removeTaskCalls[0]).toBe("resume_sync_ses_test") + expect(result).toContain("Session completed but no new response was generated") + }) + + test("removes toast when poll returns abort error", async () => { + //#given - create a context with abort signal + const controller = new AbortController() + controller.abort() + + const mockClient = { + session: { + messages: async () => ({ + data: [ + { info: { id: "msg_001", role: "user", time: { created: 1000 } } }, + { + info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "end_turn" }, + parts: [{ type: "text", text: "Response" }], + }, + ], + }), + promptAsync: async () => ({}), + status: async () => ({ + data: { ses_test: { type: "idle" } }, + }), + }, + } + + const { executeSyncContinuation } = require("./sync-continuation") + + const mockCtx = { + sessionID: "parent-session", + callID: "call-123", + metadata: () => {}, + abort: controller.signal, + } + + const mockExecutorCtx = { + client: mockClient, + } + + const args = { + session_id: "ses_test_12345678", + prompt: "test prompt", + description: "test task", + load_skills: [], + run_in_background: false, + } + + //#when - executeSyncContinuation with abort signal + const result = await executeSyncContinuation(args, mockCtx, mockExecutorCtx) + + //#then - removeTask should have been called twice (once in catch, once in finally) + expect(removeTaskCalls.length).toBe(2) + expect(result).toContain("Task aborted") + }) + + test("does not add toast when toastManager is null (no crash)", async () => { + //#given - mock task-toast-manager module to return null + const mockGetTaskToastManager = () => null + + mock.module("../../features/task-toast-manager/index.ts", () => ({ + getTaskToastManager: mockGetTaskToastManager, + TaskToastManager: class {}, + initTaskToastManager: () => null, + })) + + const mockClient = { + session: { + messages: async () => ({ + data: [ + { info: { id: "msg_001", role: "user", time: { created: 1000 } } }, + { + info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "end_turn" }, + parts: [{ type: "text", text: "Response" }], + }, + ], + }), + promptAsync: async () => ({}), + status: async () => ({ + data: { ses_test: { type: "idle" } }, + }), + }, + } + + const { executeSyncContinuation } = require("./sync-continuation") + + const mockCtx = { + sessionID: "parent-session", + callID: "call-123", + metadata: () => {}, + } + + const mockExecutorCtx = { + client: mockClient, + } + + const args = { + session_id: "ses_test_12345678", + prompt: "test prompt", + description: "test task", + load_skills: [], + run_in_background: false, + } + + //#when - executeSyncContinuation with null toastManager + let error: any = null + let result: string | null = null + try { + result = await executeSyncContinuation(args, mockCtx, mockExecutorCtx) + } catch (e) { + error = e + } + + //#then - should not crash and should complete successfully + expect(error).toBeNull() + expect(addTaskCalls.length).toBe(0) + expect(removeTaskCalls.length).toBe(0) + }) +}) diff --git a/src/tools/delegate-task/sync-result-fetcher.test.ts b/src/tools/delegate-task/sync-result-fetcher.test.ts new file mode 100644 index 000000000..436b9044e --- /dev/null +++ b/src/tools/delegate-task/sync-result-fetcher.test.ts @@ -0,0 +1,144 @@ +const { describe, test, expect } = require("bun:test") + +describe("fetchSyncResult", () => { + test("without anchor: returns latest assistant message (existing behavior)", async () => { + //#given - messages with multiple assistant responses, no anchor + const { fetchSyncResult } = require("./sync-result-fetcher") + + const mockClient = { + session: { + messages: async () => ({ + data: [ + { info: { id: "msg_001", role: "user", time: { created: 1000 } } }, + { + info: { id: "msg_002", role: "assistant", time: { created: 2000 } }, + parts: [{ type: "text", text: "First response" }], + }, + { info: { id: "msg_003", role: "user", time: { created: 3000 } } }, + { + info: { id: "msg_004", role: "assistant", time: { created: 4000 } }, + parts: [{ type: "text", text: "Latest response" }], + }, + ], + }), + }, + } + + //#when + const result = await fetchSyncResult(mockClient, "ses_test") + + //#then - should return the latest assistant message + expect(result).toEqual({ ok: true, textContent: "Latest response" }) + }) + + test("with anchor: returns only assistant messages from after anchor point", async () => { + //#given - messages with anchor at index 2 (after first assistant), should return second assistant + const { fetchSyncResult } = require("./sync-result-fetcher") + + const mockClient = { + session: { + messages: async () => ({ + data: [ + { info: { id: "msg_001", role: "user", time: { created: 1000 } } }, + { + info: { id: "msg_002", role: "assistant", time: { created: 2000 } }, + parts: [{ type: "text", text: "First response" }], + }, + { info: { id: "msg_003", role: "user", time: { created: 3000 } } }, + { + info: { id: "msg_004", role: "assistant", time: { created: 4000 } }, + parts: [{ type: "text", text: "After anchor response" }], + }, + ], + }), + }, + } + + //#when - anchor at 2 (after first assistant message) + const result = await fetchSyncResult(mockClient, "ses_test", 2) + + //#then - should return assistant message after anchor + expect(result).toEqual({ ok: true, textContent: "After anchor response" }) + }) + + test("with anchor + no new messages: returns explicit error", async () => { + //#given - anchor beyond available messages, no assistant after anchor + const { fetchSyncResult } = require("./sync-result-fetcher") + + const mockClient = { + session: { + messages: async () => ({ + data: [ + { info: { id: "msg_001", role: "user", time: { created: 1000 } } }, + { + info: { id: "msg_002", role: "assistant", time: { created: 2000 } }, + parts: [{ type: "text", text: "Response" }], + }, + ], + }), + }, + } + + //#when - anchor at 2 (beyond messages) + const result = await fetchSyncResult(mockClient, "ses_test", 2) + + //#then - should return error about no new response + expect(result.ok).toBe(false) + expect(result.error).toContain("no new response was generated") + }) + + test("with anchor + new assistant but non-terminal: returns latest terminal assistant", async () => { + //#given - anchor before multiple assistant messages, should return latest + const { fetchSyncResult } = require("./sync-result-fetcher") + + const mockClient = { + session: { + messages: async () => ({ + data: [ + { info: { id: "msg_001", role: "user", time: { created: 1000 } } }, + { + info: { id: "msg_002", role: "assistant", time: { created: 2000 } }, + parts: [{ type: "text", text: "First response" }], + }, + { info: { id: "msg_003", role: "user", time: { created: 3000 } } }, + { + info: { id: "msg_004", role: "assistant", time: { created: 3500 } }, + parts: [{ type: "text", text: "Middle response" }], + }, + { info: { id: "msg_005", role: "user", time: { created: 4000 } } }, + { + info: { id: "msg_006", role: "assistant", time: { created: 4500 } }, + parts: [{ type: "text", text: "Latest response" }], + }, + ], + }), + }, + } + + //#when - anchor at 2 (after first assistant) + const result = await fetchSyncResult(mockClient, "ses_test", 2) + + //#then - should return the latest assistant message after anchor + expect(result).toEqual({ ok: true, textContent: "Latest response" }) + }) + + test("empty messages array: returns error", async () => { + //#given - empty messages array + const { fetchSyncResult } = require("./sync-result-fetcher") + + const mockClient = { + session: { + messages: async () => ({ + data: [], + }), + }, + } + + //#when + const result = await fetchSyncResult(mockClient, "ses_test") + + //#then - should return error about no assistant response + expect(result.ok).toBe(false) + expect(result.error).toContain("No assistant response found") + }) +}) \ No newline at end of file diff --git a/src/tools/delegate-task/sync-session-poller.test.ts b/src/tools/delegate-task/sync-session-poller.test.ts index ecb0fcf38..6e5c3cd8f 100644 --- a/src/tools/delegate-task/sync-session-poller.test.ts +++ b/src/tools/delegate-task/sync-session-poller.test.ts @@ -282,48 +282,144 @@ describe("pollSyncSession", () => { }) }) - 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 + const { pollSyncSession } = require("./sync-session-poller") - let statusCallCount = 0 - let messageCallCount = 0 - const mockClient = { - session: { - messages: async () => { - messageCallCount++ - return { - data: [ - { info: { id: "msg_001", role: "user", time: { created: 1000 } } }, - { - info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "end_turn" }, - parts: [{ type: "text", text: "Done" }], - }, - ], - } - }, - status: async () => { - statusCallCount++ - if (statusCallCount <= 2) { - return { data: { "ses_busy": { type: "running" } } } - } - return { data: { "ses_busy": { type: "idle" } } } - }, - }, - } + let statusCallCount = 0 + let messageCallCount = 0 + const mockClient = { + session: { + messages: async () => { + messageCallCount++ + return { + data: [ + { info: { id: "msg_001", role: "user", time: { created: 1000 } } }, + { + info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "end_turn" }, + parts: [{ type: "text", text: "Done" }], + }, + ], + } + }, + status: async () => { + statusCallCount++ + if (statusCallCount <= 2) { + return { data: { "ses_busy": { type: "running" } } } + } + return { data: { "ses_busy": { type: "idle" } } } + }, + }, + } - //#when - const result = await pollSyncSession(createMockCtx(), mockClient, { - sessionID: "ses_busy", - agentToUse: "test-agent", - toastManager: null, - taskId: undefined, - }) + //#when + 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 - should have waited for idle before checking messages + expect(result).toBeNull() + expect(statusCallCount).toBeGreaterThanOrEqual(3) + }) + }) + + describe("isSessionComplete edge cases", () => { + const { isSessionComplete } = require("./sync-session-poller") + + test("returns false when messages array is empty", () => { + //#given - empty messages array + const messages: any[] = [] + + //#when + const result = isSessionComplete(messages) + + //#then - should return false + expect(result).toBe(false) + }) + + test("returns false when no assistant message exists", () => { + //#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 + const result = isSessionComplete(messages) + + //#then - should return false + expect(result).toBe(false) + }) + + test("returns false when only assistant message exists (no user)", () => { + //#given - only assistant message, no user message + const messages = [ + { + info: { id: "msg_001", role: "assistant", time: { created: 1000 }, finish: "end_turn" }, + parts: [{ type: "text", text: "Response" }], + }, + ] + + //#when + const result = isSessionComplete(messages) + + //#then - should return false (no user message to compare IDs) + expect(result).toBe(false) + }) + + test("returns false when assistant message has missing finish field", () => { + //#given - assistant message without finish field + const messages = [ + { info: { id: "msg_001", role: "user", time: { created: 1000 } } }, + { + info: { id: "msg_002", role: "assistant", time: { created: 2000 } }, + parts: [{ type: "text", text: "Response" }], + }, + ] + + //#when + const result = isSessionComplete(messages) + + //#then - should return false (missing finish) + expect(result).toBe(false) + }) + + test("returns false when assistant message has missing info.id field", () => { + //#given - assistant message without id in info + const messages = [ + { info: { id: "msg_001", role: "user", time: { created: 1000 } } }, + { + info: { role: "assistant", time: { created: 2000 }, finish: "end_turn" }, + parts: [{ type: "text", text: "Response" }], + }, + ] + + //#when + const result = isSessionComplete(messages) + + //#then - should return false (missing assistant id) + expect(result).toBe(false) + }) + + test("returns false when user message has missing info.id field", () => { + //#given - user message without id in info + const messages = [ + { info: { role: "user", time: { created: 1000 } } }, + { + info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "end_turn" }, + parts: [{ type: "text", text: "Response" }], + }, + ] + + //#when + const result = isSessionComplete(messages) + + //#then - should return false (missing user id) + expect(result).toBe(false) + }) + }) + })