{"version":3,"file":"interactive-mode-subscribe.test.d.ts","sourceRoot":"","sources":["../../../src/modes/interactive/interactive-mode-subscribe.test.ts"],"names":[],"mappings":"","sourcesContent":["import { Container } from \"@apholdings/jensen-tui\";\nimport stripAnsi from \"strip-ansi\";\nimport { beforeAll, describe, expect, it, vi } from \"vitest\";\nimport type { AgentSessionEvent, AgentSessionEventListener } from \"../../core/agent-session.js\";\nimport type { Task } from \"../../core/memory.js\";\nimport type { WorkingContext } from \"../../core/working-context.js\";\nimport { SidebarTodoPanel } from \"./components/sidebar-todo-panel.js\";\nimport { InteractiveMode, mountOperatorStack } from \"./interactive-mode.js\";\nimport { initTheme } from \"./theme/theme.js\";\n\n/**\n * Proof for the subscribe indirection seam.\n *\n * The operator-stack tests already prove handleEvent → panels. These tests prove\n * the real path one level up: subscribeToAgent() → session.subscribe() → listener\n * → handleEvent() → panels.\n *\n * subscribeToAgent() is called once inside init() (interactive-mode.ts:1355).\n * init() is double-guarded (isInitialized + initializing) so it can never run\n * twice for the same InteractiveMode instance. The stored this.unsubscribe\n * function splices the listener out of the session's listener array on demand.\n * The sessionEpoch check at the top of handleEvent drops events that were\n * captured under a different epoch (stale-session guard).\n */\n\nbeforeAll(() => {\n\tinitTheme(\"dark\");\n});\n\n/** Minimal session with real subscribe/unsubscribe semantics and a test emit helper. */\nclass SubscribableMockSession {\n\treadonly listeners: AgentSessionEventListener[] = [];\n\n\t_todos: Array<{ content: string; activeForm: string; status: \"pending\" | \"in_progress\" | \"completed\" }> = [];\n\t_tasks: Task[] = [];\n\n\tsubscribe(listener: AgentSessionEventListener): () => void {\n\t\tthis.listeners.push(listener);\n\t\treturn () => {\n\t\t\tconst i = this.listeners.indexOf(listener);\n\t\t\tif (i !== -1) this.listeners.splice(i, 1);\n\t\t};\n\t}\n\n\t/** Emit an event and await all listener promises (listeners are async). */\n\tasync emit(event: AgentSessionEvent): Promise<void> {\n\t\tawait Promise.all(this.listeners.map((l) => l(event)));\n\t}\n\n\tgetTodos() {\n\t\treturn this._todos;\n\t}\n\n\tgetTasks(): Task[] {\n\t\treturn this._tasks;\n\t}\n\n\tgetWorkingContext(): WorkingContext {\n\t\tconst todos = this._todos;\n\t\tconst tasks = this._tasks;\n\t\treturn {\n\t\t\tmemory: {\n\t\t\t\titemCount: 0,\n\t\t\t\tstaleCount: 0,\n\t\t\t\tkeyPreview: [],\n\t\t\t\tisPersisted: true,\n\t\t\t\tscope: \"current_branch_session_state\",\n\t\t\t},\n\t\t\ttodo: {\n\t\t\t\ttotal: todos.length,\n\t\t\t\tcompleted: todos.filter((t) => t.status === \"completed\").length,\n\t\t\t\tinProgress: todos.find((t) => t.status === \"in_progress\")?.activeForm,\n\t\t\t\tisPersisted: true,\n\t\t\t\tscope: \"current_branch_session_state\",\n\t\t\t},\n\t\t\ttasks: {\n\t\t\t\ttotal: tasks.length,\n\t\t\t\tpending: tasks.filter((t) => t.status === \"pending\").length,\n\t\t\t\tinProgress: tasks.filter((t) => t.status === \"in_progress\").length,\n\t\t\t\tcompleted: tasks.filter((t) => t.status === \"completed\").length,\n\t\t\t\tinProgressTask: tasks.find((t) => t.status === \"in_progress\")\n\t\t\t\t\t? {\n\t\t\t\t\t\t\tid: tasks.find((t) => t.status === \"in_progress\")!.id,\n\t\t\t\t\t\t\tsubject: tasks.find((t) => t.status === \"in_progress\")!.subject,\n\t\t\t\t\t\t\tactiveForm: tasks.find((t) => t.status === \"in_progress\")!.activeForm,\n\t\t\t\t\t\t}\n\t\t\t\t\t: undefined,\n\t\t\t\tisPersisted: true,\n\t\t\t\tscope: \"current_branch_session_state\",\n\t\t\t},\n\t\t\tdelegatedWork: {\n\t\t\t\tactiveCount: 0,\n\t\t\t\tcompletedCount: 0,\n\t\t\t\tfailedCount: 0,\n\t\t\t\tactiveAgents: [],\n\t\t\t\tfailurePreview: [],\n\t\t\t\tisPersisted: false,\n\t\t\t\tscope: \"current_process_runtime_state\",\n\t\t\t\tnote: \"live current-process state only; not persisted and resets on session switch/resume\",\n\t\t\t},\n\t\t} as WorkingContext;\n\t}\n}\n\ninterface SubscribeHarness {\n\tmode: {\n\t\tsessionEpoch: number;\n\t\tisInitialized: boolean;\n\t\tunsubscribe: (() => void) | undefined;\n\t\tinit: () => Promise<void>;\n\t};\n\tsession: SubscribableMockSession;\n\tui: Container & { requestRender: () => void };\n\tsidebarTodoPanel: SidebarTodoPanel;\n\tsidebarTaskPanel: SidebarTodoPanel;\n}\n\nfunction createSubscribeHarness(): SubscribeHarness {\n\tconst session = new SubscribableMockSession();\n\tconst ui = new Container() as Container & { requestRender: () => void };\n\tui.requestRender = vi.fn();\n\n\tconst sidebarTodoPanel = new SidebarTodoPanel({ title: \"Todos\" });\n\tconst sidebarTaskPanel = new SidebarTodoPanel({ title: \"Tasks\" });\n\n\tmountOperatorStack(ui, sidebarTodoPanel, sidebarTaskPanel);\n\n\tconst mode = Object.assign(Object.create(InteractiveMode.prototype), {\n\t\tsession,\n\t\tui,\n\t\tsidebarTodoPanel,\n\t\tsidebarTaskPanel,\n\t\tfooter: { invalidate: vi.fn() },\n\t\tisInitialized: true,\n\t\tsessionEpoch: 1,\n\t\tunsubscribe: undefined,\n\t}) as SubscribeHarness[\"mode\"];\n\n\treturn { mode, session, ui, sidebarTodoPanel, sidebarTaskPanel };\n}\n\nfunction renderStack(harness: SubscribeHarness, width = 100): string {\n\treturn stripAnsi(harness.ui.render(width).join(\"\\n\"));\n}\n\ndescribe(\"InteractiveMode subscribe wiring (subscribeToAgent → session.subscribe → handleEvent → panels)\", () => {\n\tit(\"subscribeToAgent registers exactly one listener on the session\", () => {\n\t\tconst { mode, session } = createSubscribeHarness();\n\n\t\texpect(session.listeners).toHaveLength(0);\n\t\t(mode as unknown as { subscribeToAgent(): void }).subscribeToAgent();\n\t\texpect(session.listeners).toHaveLength(1);\n\t\texpect(mode.unsubscribe).toBeTypeOf(\"function\");\n\t});\n\n\tit(\"todo_update emitted through subscribe path reaches handleEvent and updates the todo panel\", async () => {\n\t\tconst { mode, session, sidebarTodoPanel } = createSubscribeHarness();\n\t\t(mode as unknown as { subscribeToAgent(): void }).subscribeToAgent();\n\n\t\tsession._todos.push(\n\t\t\t{ content: \"First task\", activeForm: \"Working on first task\", status: \"in_progress\" },\n\t\t\t{ content: \"Second task\", activeForm: \"\", status: \"pending\" },\n\t\t);\n\t\tawait session.emit({ type: \"todo_update\", todos: session._todos });\n\n\t\tconst rendered = stripAnsi(sidebarTodoPanel.render(100).join(\"\\n\"));\n\t\texpect(rendered).toContain(\"Todos\");\n\t\texpect(rendered).toContain(\"Working on first task\");\n\t\texpect(rendered).toContain(\"Second task\");\n\t});\n\n\tit(\"task_update emitted through subscribe path reaches handleEvent and updates the task panel\", async () => {\n\t\tconst { mode, session, sidebarTaskPanel } = createSubscribeHarness();\n\t\t(mode as unknown as { subscribeToAgent(): void }).subscribeToAgent();\n\n\t\tsession._tasks.push(\n\t\t\t{ id: \"t1\", subject: \"Ship feature\", description: \"\", status: \"in_progress\", activeForm: \"Shipping feature\" },\n\t\t\t{ id: \"t2\", subject: \"Write docs\", description: \"\", status: \"pending\" },\n\t\t);\n\t\tawait session.emit({ type: \"task_update\", tasks: session._tasks });\n\n\t\tconst rendered = stripAnsi(sidebarTaskPanel.render(100).join(\"\\n\"));\n\t\texpect(rendered).toContain(\"Tasks\");\n\t\texpect(rendered).toContain(\"Shipping feature\");\n\t\texpect(rendered).toContain(\"Write docs\");\n\t});\n\n\tit(\"both panels update from a single subscribe path emission (full stack)\", async () => {\n\t\tconst harness = createSubscribeHarness();\n\t\t(harness.mode as unknown as { subscribeToAgent(): void }).subscribeToAgent();\n\n\t\tharness.session._todos.push({ content: \"Todo item\", activeForm: \"Doing it\", status: \"in_progress\" });\n\t\tharness.session._tasks.push({\n\t\t\tid: \"t1\",\n\t\t\tsubject: \"Task item\",\n\t\t\tdescription: \"\",\n\t\t\tstatus: \"in_progress\",\n\t\t\tactiveForm: \"Doing task\",\n\t\t});\n\n\t\tawait harness.session.emit({ type: \"todo_update\", todos: harness.session._todos });\n\t\tawait harness.session.emit({ type: \"task_update\", tasks: harness.session._tasks });\n\n\t\tconst stack = renderStack(harness);\n\t\texpect(stack).toContain(\"Todos\");\n\t\texpect(stack).toContain(\"Tasks\");\n\t\texpect(stack).toContain(\"Doing it\");\n\t\texpect(stack).toContain(\"Doing task\");\n\t});\n\n\tit(\"unsubscribe removes the listener and subsequent events are not delivered to panels\", async () => {\n\t\tconst { mode, session, sidebarTodoPanel } = createSubscribeHarness();\n\t\t(mode as unknown as { subscribeToAgent(): void }).subscribeToAgent();\n\n\t\texpect(session.listeners).toHaveLength(1);\n\n\t\t// Call the stored unsubscribe function\n\t\tmode.unsubscribe!();\n\t\texpect(session.listeners).toHaveLength(0);\n\n\t\t// Emit after unsubscribe — panel must stay empty\n\t\tsession._todos.push({ content: \"Should not appear\", activeForm: \"Working\", status: \"in_progress\" });\n\t\tawait session.emit({ type: \"todo_update\", todos: session._todos });\n\n\t\texpect(sidebarTodoPanel.render(100)).toEqual([]);\n\t});\n\n\tit(\"stale event is dropped when sessionEpoch changes during async init\", async () => {\n\t\tconst { mode, session, sidebarTodoPanel } = createSubscribeHarness();\n\n\t\t// Start uninitialized so handleEvent will await init()\n\t\tmode.isInitialized = false;\n\t\tmode.sessionEpoch = 1;\n\n\t\t// Replace init with a mock that increments epoch before yielding — simulates a\n\t\t// session switch arriving while a previous event is waiting for initialization.\n\t\tlet initResolve!: () => void;\n\t\tconst initBarrier = new Promise<void>((res) => {\n\t\t\tinitResolve = res;\n\t\t});\n\t\t(mode as unknown as { init(): Promise<void> }).init = async () => {\n\t\t\tmode.sessionEpoch++; // epoch now 2; event captured epoch 1 → will be dropped\n\t\t\tmode.isInitialized = true;\n\t\t\tawait initBarrier;\n\t\t};\n\n\t\t(mode as unknown as { subscribeToAgent(): void }).subscribeToAgent();\n\n\t\tsession._todos.push({ content: \"Stale todo\", activeForm: \"Working\", status: \"in_progress\" });\n\n\t\t// Emit fires the async listener; it awaits init() which increments epoch then\n\t\t// blocks on initBarrier. We resolve the barrier so handleEvent can finish.\n\t\tconst emitPromise = session.emit({ type: \"todo_update\", todos: session._todos });\n\t\tinitResolve();\n\t\tawait emitPromise;\n\n\t\t// Panel must remain empty: epoch mismatch caused handleEvent to return early\n\t\texpect(sidebarTodoPanel.render(100)).toEqual([]);\n\t});\n\n\tit(\"calling subscribeToAgent twice leaves exactly one live listener (idempotent via unsubscribe-before-resubscribe)\", () => {\n\t\t// subscribeToAgent() calls this.unsubscribe?.() before registering a new listener,\n\t\t// so a second call splices out the first listener and registers a fresh one.\n\t\t// Exactly one listener remains active regardless of how many times it is called.\n\t\tconst { mode, session } = createSubscribeHarness();\n\n\t\t(mode as unknown as { subscribeToAgent(): void }).subscribeToAgent();\n\t\t(mode as unknown as { subscribeToAgent(): void }).subscribeToAgent();\n\n\t\texpect(session.listeners).toHaveLength(1);\n\t});\n\n\tit(\"double subscribeToAgent does not duplicate event delivery to panels\", async () => {\n\t\tconst { mode, session, sidebarTodoPanel } = createSubscribeHarness();\n\n\t\t(mode as unknown as { subscribeToAgent(): void }).subscribeToAgent();\n\t\t(mode as unknown as { subscribeToAgent(): void }).subscribeToAgent();\n\n\t\t// Only one listener remains; emitting once should update the panel exactly once.\n\t\t// If two listeners were present, the panel would still render correctly but\n\t\t// handleEvent would run twice — this proves it runs only once per emit.\n\t\tsession._todos.push({ content: \"Dedup check\", activeForm: \"Running dedup check\", status: \"in_progress\" });\n\t\tawait session.emit({ type: \"todo_update\", todos: session._todos });\n\n\t\tconst rendered = stripAnsi(sidebarTodoPanel.render(100).join(\"\\n\"));\n\t\texpect(rendered).toContain(\"Running dedup check\");\n\t\texpect(session.listeners).toHaveLength(1);\n\t});\n});\n"]}