From cf614b5f4f97a0522241bf8aa6ca8622fcb19f8f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 19 May 2026 00:28:55 -0700 Subject: [PATCH] Fix agent completion notification dedupe (#2304) Co-authored-by: Orca --- src/main/agent-hooks/server.test.ts | 37 ++ src/main/agent-hooks/server.ts | 16 +- src/main/index.ts | 18 +- src/main/ipc/pty.test.ts | 34 ++ src/main/ipc/pty.ts | 11 + .../components/settings/NotificationsPane.tsx | 45 ++ .../agent-completion-coordinator-types.ts | 25 + .../agent-completion-coordinator.test.ts | 410 +++++++++++++++ .../agent-completion-coordinator.ts | 388 ++++++++++++++ .../agent-process-inspection-queue.ts | 80 +++ .../terminal-pane/pty-connection.test.ts | 486 ++++++++++++++++++ .../terminal-pane/pty-connection.ts | 88 +++- .../terminal-pane/title-agent-identity.ts | 28 + .../use-notification-dispatch.ts | 129 ++--- ...gent-hook-completion-notifications.test.ts | 93 ++++ .../agent-hook-completion-notifications.ts | 123 +++++ src/renderer/src/hooks/useIpcEvents.ts | 28 +- src/renderer/src/store/slices/ui.ts | 1 + src/shared/agent-hook-endpoint-file.test.ts | 65 +++ src/shared/agent-hook-endpoint-file.ts | 42 ++ src/shared/agent-process-recognition.test.ts | 12 + src/shared/agent-process-recognition.ts | 77 +++ tests/e2e/droid-notification.spec.ts | 204 ++++++++ tests/e2e/helpers/agent-hook-endpoint.ts | 79 +++ tests/e2e/notification-settings.spec.ts | 94 ++++ 25 files changed, 2531 insertions(+), 82 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts create mode 100644 src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts create mode 100644 src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts create mode 100644 src/renderer/src/components/terminal-pane/agent-process-inspection-queue.ts create mode 100644 src/renderer/src/components/terminal-pane/title-agent-identity.ts create mode 100644 src/renderer/src/hooks/agent-hook-completion-notifications.test.ts create mode 100644 src/renderer/src/hooks/agent-hook-completion-notifications.ts create mode 100644 src/shared/agent-hook-endpoint-file.test.ts create mode 100644 src/shared/agent-hook-endpoint-file.ts create mode 100644 src/shared/agent-process-recognition.test.ts create mode 100644 src/shared/agent-process-recognition.ts create mode 100644 tests/e2e/helpers/agent-hook-endpoint.ts create mode 100644 tests/e2e/notification-settings.spec.ts diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 7df50d5ac83..843e1837938 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -2179,6 +2179,43 @@ describe('Endpoint file lifecycle', () => { } }) + it('buildPtyEnv includes namespaced ORCA_AGENT_HOOK_ENDPOINT for development servers', async () => { + const server = new AgentHookServer() + await server.start({ + env: 'development', + userDataPath, + endpointNamespace: 'com.stablyai.orca.dev.test123' + }) + try { + const env = server.buildPtyEnv() + expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBe(server.endpointFilePath) + expect(env.ORCA_AGENT_HOOK_ENDPOINT).toContain('com.stablyai.orca.dev.test123') + expect(env.ORCA_AGENT_HOOK_PORT).toBeTruthy() + expect(env.ORCA_AGENT_HOOK_TOKEN).toBeTruthy() + } finally { + server.stop() + } + }) + + it('keeps endpoint files separate for parallel dev namespaces', async () => { + const firstServer = new AgentHookServer() + const secondServer = new AgentHookServer() + await firstServer.start({ env: 'development', userDataPath, endpointNamespace: 'dev-a' }) + await secondServer.start({ env: 'development', userDataPath, endpointNamespace: 'dev-b' }) + try { + expect(firstServer.endpointFilePath).not.toBe(secondServer.endpointFilePath) + expect(firstServer.buildPtyEnv().ORCA_AGENT_HOOK_ENDPOINT).toBe(firstServer.endpointFilePath) + expect(secondServer.buildPtyEnv().ORCA_AGENT_HOOK_ENDPOINT).toBe( + secondServer.endpointFilePath + ) + expect(existsSync(firstServer.endpointFilePath!)).toBe(true) + expect(existsSync(secondServer.endpointFilePath!)).toBe(true) + } finally { + firstServer.stop() + secondServer.stop() + } + }) + it('buildPtyEnv omits ORCA_AGENT_HOOK_ENDPOINT when no userDataPath was provided', async () => { // Why: the endpoint file is opt-in via start({ userDataPath }). In tests // and in the packaged main-process path where userData is unset for any diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index db4db9f4449..ce16aa0d65a 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -597,7 +597,11 @@ export class AgentHookServer { this.applyNormalizedStatus(event) } - async start(options?: { env?: string; userDataPath?: string }): Promise { + async start(options?: { + env?: string + userDataPath?: string + endpointNamespace?: string + }): Promise { if (this.server) { return } @@ -606,7 +610,12 @@ export class AgentHookServer { this.env = options.env } if (options?.userDataPath) { - this.endpointDir = join(options.userDataPath, 'agent-hooks') + // Why: dev builds share one userData path, so callers can namespace the + // endpoint file by dev instance while packaged builds keep the stable path + // that lets long-lived PTYs reconnect after app restart. + this.endpointDir = options.endpointNamespace + ? join(options.userDataPath, 'agent-hooks', options.endpointNamespace) + : join(options.userDataPath, 'agent-hooks') this.endpointFilePathCache = join(this.endpointDir, getEndpointFileName()) this.lastStatusFilePath = join(this.endpointDir, LAST_STATUS_FILE_NAME) } @@ -781,6 +790,9 @@ export class AgentHookServer { ORCA_AGENT_HOOK_ENV: this.env, ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION } + // Why: managed hooks source this file at invocation time. Packaged builds + // use a stable file for restart handoff; dev callers pass a per-instance + // namespace so parallel `pnpm dev` runs do not steal each other's hooks. if (this.endpointFileWritten && this.endpointFilePathCache) { env.ORCA_AGENT_HOOK_ENDPOINT = this.endpointFilePathCache } diff --git a/src/main/index.ts b/src/main/index.ts index f7707272982..fa07a5c9fe1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -108,6 +108,9 @@ let watcherShutdownDone = false let automations: AutomationService | null = null const isServeMode = process.argv.includes('--serve') const devInstanceIdentity = getDevInstanceIdentity(is.dev) +const devAgentHookEndpointNamespace = devInstanceIdentity.isDev + ? devInstanceIdentity.appUserModelId + : undefined installUncaughtPipeErrorGuard() // Why: propagate the Orca app version into `process.env` so PTY-env @@ -164,12 +167,11 @@ function focusExistingWindow(): void { // // Why skip in dev: engineers routinely run `pnpm dev` in parallel from // multiple worktrees while shipping features, and the lock makes the second -// `pnpm dev` exit silently. In dev we accept that `orca-runtime.json` and -// `endpoint.env` may race (the bundled `orca-dev` CLI / agent hooks route -// to whichever instance wrote last). The dev build is not used for real -// agent work, so that routing ambiguity is acceptable. Packaged Orca keeps -// the lock to protect against the corruption documented in PR #1326 / -// issue #1312. +// `pnpm dev` exit silently. In dev we accept that `orca-runtime.json` may race +// (the bundled `orca-dev` CLI routes to whichever instance wrote last). Agent +// hook endpoint files are namespaced per dev instance when the hook server +// starts below. Packaged Orca keeps the lock to protect against the corruption +// documented in PR #1326 / issue #1312. const hasSingleInstanceLock = is.dev && !isServeMode ? true : acquireSingleInstanceLock(app, focusExistingWindow) if (!hasSingleInstanceLock) { @@ -941,7 +943,9 @@ app.whenReady().then(async () => { env: app.isPackaged ? 'production' : 'development', // Why: hooks source this endpoint file at invocation time, so old PTY // env still reaches the current Orca process after an app restart. - userDataPath: app.getPath('userData') + // Dev uses a namespace because all worktrees share `orca-dev`. + userDataPath: app.getPath('userData'), + endpointNamespace: devAgentHookEndpointNamespace }), onDaemonError: (error) => { console.error('[daemon] Failed to start daemon PTY provider, falling back to local:', error) diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index f395e02f3e4..38f15fe68e8 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -579,6 +579,40 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token') }) + it('strips stale inherited hook receiver env before injecting this runtime', async () => { + const env = await spawnAndGetEnv({ + ORCA_AGENT_HOOK_PORT: '1111', + ORCA_AGENT_HOOK_TOKEN: 'stale-token', + ORCA_AGENT_HOOK_ENV: 'production', + ORCA_AGENT_HOOK_VERSION: 'stale-version', + ORCA_AGENT_HOOK_ENDPOINT: '/tmp/stale-endpoint.env' + }) + + expect(env.ORCA_AGENT_HOOK_PORT).toBe('5678') + expect(env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token') + expect(env.ORCA_AGENT_HOOK_ENV).toBeUndefined() + expect(env.ORCA_AGENT_HOOK_VERSION).toBeUndefined() + expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined() + }) + + it('does not leak inherited hook receiver env if the hook server is unavailable', async () => { + buildAgentHookEnvMock.mockReturnValueOnce({}) + + const env = await spawnAndGetEnv({ + ORCA_AGENT_HOOK_PORT: '1111', + ORCA_AGENT_HOOK_TOKEN: 'stale-token', + ORCA_AGENT_HOOK_ENV: 'production', + ORCA_AGENT_HOOK_VERSION: 'stale-version', + ORCA_AGENT_HOOK_ENDPOINT: '/tmp/stale-endpoint.env' + }) + + expect(env.ORCA_AGENT_HOOK_PORT).toBeUndefined() + expect(env.ORCA_AGENT_HOOK_TOKEN).toBeUndefined() + expect(env.ORCA_AGENT_HOOK_ENV).toBeUndefined() + expect(env.ORCA_AGENT_HOOK_VERSION).toBeUndefined() + expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined() + }) + it('prepends local git/gh attribution shims when attribution is enabled', async () => { const env = await spawnAndGetEnv(undefined, undefined, undefined, () => ({ enableGitHubAttribution: true diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 508d0ae04a8..4a1454292d6 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -80,6 +80,14 @@ const ptyPaneKey = new Map() // Kept in lock-step with ptyPaneKey via the same spawn and teardown sites. const paneKeyPtyId = new Map() +const AGENT_HOOK_RUNTIME_ENV_KEYS = [ + 'ORCA_AGENT_HOOK_PORT', + 'ORCA_AGENT_HOOK_TOKEN', + 'ORCA_AGENT_HOOK_ENV', + 'ORCA_AGENT_HOOK_VERSION', + 'ORCA_AGENT_HOOK_ENDPOINT' +] as const + export function getPtyIdForPaneKey(paneKey: string): string | undefined { return paneKeyPtyId.get(paneKey) } @@ -305,6 +313,9 @@ export function buildPtyHostEnv( // must inject the loopback receiver coordinates before the agent starts. // Without these env vars the global hook config cannot map callbacks back // to the correct Orca pane. + for (const key of AGENT_HOOK_RUNTIME_ENV_KEYS) { + delete baseEnv[key] + } Object.assign(baseEnv, agentHookServer.buildPtyEnv()) // Why: PI_CODING_AGENT_DIR owns Pi's full config/session root. Build a diff --git a/src/renderer/src/components/settings/NotificationsPane.tsx b/src/renderer/src/components/settings/NotificationsPane.tsx index 8eaa7f8dbd2..8b89abdce0a 100644 --- a/src/renderer/src/components/settings/NotificationsPane.tsx +++ b/src/renderer/src/components/settings/NotificationsPane.tsx @@ -47,6 +47,25 @@ type NotificationsPaneProps = { updateSettings: (updates: Partial) => void } +function getRendererNotificationPermission(): NotificationPermission | null { + if (typeof window.Notification === 'undefined') { + return null + } + return window.Notification.permission +} + +function showNotificationPermissionDeniedToast(): void { + toast.error('Notifications are blocked in macOS', { + description: 'Enable notifications for this Orca app in System Settings.', + action: { + label: 'Open Settings', + onClick: () => { + void window.api.notifications.openSystemSettings() + } + } + }) +} + export function NotificationsPane({ settings, updateSettings @@ -64,6 +83,20 @@ export function NotificationsPane({ } const handleSendTestNotification = async (): Promise => { + // Why: Electron main cannot reliably read macOS notification authorization, + // but the renderer exposes it. Without this check, dev builds can report + // "sent" while macOS silently drops the notification. + if (getRendererNotificationPermission() === 'denied') { + showNotificationPermissionDeniedToast() + return + } + + const permissionStatus = await window.api.notifications.getPermissionStatus() + if (!permissionStatus.supported) { + toast.error('Notifications are not supported on this system') + return + } + const result = await window.api.notifications.dispatch({ source: 'test' }) if (result.delivered) { // Why: the Test button must always play through, even if the user clicks @@ -77,7 +110,19 @@ export function NotificationsPane({ return } toast.success('Test notification sent') + return } + + if (getRendererNotificationPermission() === 'denied') { + showNotificationPermissionDeniedToast() + return + } + + toast.error( + result.reason === 'disabled' + ? 'Notifications are disabled' + : 'Test notification was not delivered' + ) } const handleChooseSound = async (): Promise => { diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts new file mode 100644 index 00000000000..8df083213fc --- /dev/null +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts @@ -0,0 +1,25 @@ +import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' +import type { GlobalSettings } from '../../../../shared/types' +import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection' + +export type AgentCompletionCoordinatorOptions = { + paneKey: string + getPtyId: () => string | null + getSettings: () => Pick | null | undefined + inspectProcess: ( + settings: Pick | null | undefined, + ptyId: string + ) => Promise + dispatchCompletion: (title: string) => void + isLive: () => boolean +} + +export type AgentCompletionCoordinator = { + observeTitle: (title: string) => void + observeClassifiedTitleCompletion: (title: string) => void + observeTitleWorking: () => void + observeHookStatus: (payload: ParsedAgentStatusPayload) => void + startProcessTracking: () => void + resetCompletionState: (options?: { requireFreshWorking?: boolean }) => void + dispose: () => void +} diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts new file mode 100644 index 00000000000..a63c27e42b5 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts @@ -0,0 +1,410 @@ +/* oxlint-disable max-lines */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createAgentCompletionCoordinator } from './agent-completion-coordinator' +import { resetAgentProcessInspectionQueueForTests } from './agent-process-inspection-queue' +import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection' + +async function flushAsyncTicks(count = 4): Promise { + for (let i = 0; i < count; i++) { + await Promise.resolve() + } +} + +function processResult(foregroundProcess: string | null): RuntimeTerminalProcessInspection { + return { foregroundProcess, hasChildProcesses: foregroundProcess !== null } +} + +function createDeferred(): { promise: Promise; resolve: (value: T) => void } { + let resolveDeferred!: (value: T) => void + const promise = new Promise((resolve) => { + resolveDeferred = resolve + }) + return { promise, resolve: resolveDeferred } +} + +describe('agent completion coordinator', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.spyOn(Math, 'random').mockReturnValue(0.5) + }) + + afterEach(() => { + resetAgentProcessInspectionQueueForTests() + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('clears process evidence after agent exit so later non-agent spinner titles do not notify', async () => { + let foregroundProcess: string | null = 'codex' + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(async () => processResult(foregroundProcess)), + dispatchCompletion, + isLive: () => true + }) + + coordinator.startProcessTracking() + vi.advanceTimersByTime(2_000) + await flushAsyncTicks() + + coordinator.observeTitle('⠋ codex') + coordinator.observeTitle('codex done') + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + + foregroundProcess = 'zsh' + vi.advanceTimersByTime(750) + await flushAsyncTicks() + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + + dispatchCompletion.mockClear() + coordinator.observeTitle('⠋ experimental-agent-observability') + coordinator.observeTitle('experimental-agent-observability') + await flushAsyncTicks() + + expect(dispatchCompletion).not.toHaveBeenCalled() + }) + + it('suppresses process-exit backstop after a title completion already notified the turn', async () => { + let foregroundProcess: string | null = 'codex' + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(async () => processResult(foregroundProcess)), + dispatchCompletion, + isLive: () => true + }) + + coordinator.startProcessTracking() + vi.advanceTimersByTime(2_000) + await flushAsyncTicks() + + coordinator.observeTitle('⠋ codex') + coordinator.observeTitle('codex done') + foregroundProcess = null + vi.advanceTimersByTime(750) + await flushAsyncTicks() + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + expect(dispatchCompletion).toHaveBeenCalledWith('codex done') + }) + + it('suppresses same-turn title completion after a hook completion already notified', () => { + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + + coordinator.observeHookStatus({ + state: 'working', + prompt: '', + agentType: 'codex' + }) + coordinator.observeHookStatus({ + state: 'done', + prompt: '', + agentType: 'codex' + }) + coordinator.observeClassifiedTitleCompletion('codex done') + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + expect(dispatchCompletion).toHaveBeenCalledWith('codex') + }) + + it('ignores stale working title state after a hook completion already notified', () => { + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + + coordinator.observeHookStatus({ + state: 'done', + prompt: '', + agentType: 'codex' + }) + coordinator.observeTitle('⠋ codex') + coordinator.observeTitle('codex done') + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + expect(dispatchCompletion).toHaveBeenCalledWith('codex') + }) + + it('suppresses delayed title completion after process inspection changes sessions', async () => { + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(async () => processResult('codex')), + dispatchCompletion, + isLive: () => true + }) + + coordinator.observeHookStatus({ + state: 'done', + prompt: '', + agentType: 'codex' + }) + coordinator.startProcessTracking() + vi.advanceTimersByTime(2_000) + await flushAsyncTicks() + coordinator.observeClassifiedTitleCompletion('codex done') + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + expect(dispatchCompletion).toHaveBeenCalledWith('codex') + }) + + it('suppresses late process-exit backstop after process inspection follows hook completion', async () => { + let foregroundProcess: string | null = 'codex' + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(async () => processResult(foregroundProcess)), + dispatchCompletion, + isLive: () => true + }) + + coordinator.observeHookStatus({ + state: 'done', + prompt: '', + agentType: 'codex' + }) + coordinator.startProcessTracking() + vi.advanceTimersByTime(2_000) + await flushAsyncTicks() + foregroundProcess = null + vi.advanceTimersByTime(750) + await flushAsyncTicks() + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + expect(dispatchCompletion).toHaveBeenCalledWith('codex') + }) + + it('keeps duplicate done-only hooks inside replay guard suppressed after process inspection', async () => { + const inspection = createDeferred() + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(() => inspection.promise), + dispatchCompletion, + isLive: () => true + }) + + coordinator.startProcessTracking() + vi.advanceTimersByTime(2_000) + await flushAsyncTicks() + coordinator.observeHookStatus({ + state: 'done', + prompt: '', + agentType: 'codex' + }) + inspection.resolve(processResult('codex')) + await flushAsyncTicks() + coordinator.observeHookStatus({ + state: 'done', + prompt: '', + agentType: 'codex' + }) + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + }) + + it('can require a fresh working signal after completion state reset', () => { + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + + coordinator.observeHookStatus({ + state: 'done', + prompt: '', + agentType: 'codex' + }) + coordinator.resetCompletionState({ requireFreshWorking: true }) + coordinator.observeClassifiedTitleCompletion('codex done') + coordinator.observeHookStatus({ + state: 'done', + prompt: '', + agentType: 'codex' + }) + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + + coordinator.observeHookStatus({ + state: 'working', + prompt: '', + agentType: 'codex' + }) + coordinator.observeHookStatus({ + state: 'done', + prompt: '', + agentType: 'codex' + }) + + expect(dispatchCompletion).toHaveBeenCalledTimes(2) + }) + + it('ignores process inspections that resolve after completion state reset', async () => { + const inspection = createDeferred() + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(() => inspection.promise), + dispatchCompletion, + isLive: () => true + }) + + coordinator.startProcessTracking() + vi.advanceTimersByTime(2_000) + coordinator.resetCompletionState({ requireFreshWorking: true }) + inspection.resolve(processResult('codex')) + await flushAsyncTicks() + coordinator.observeTitle('⠋ experimental-agent-observability') + coordinator.observeTitle('experimental-agent-observability') + + expect(dispatchCompletion).not.toHaveBeenCalled() + }) + + it('starts a fresh pending-title inspection after stale inspection resolves', async () => { + const firstInspection = createDeferred() + const secondInspection = createDeferred() + const inspectProcess = vi + .fn() + .mockReturnValueOnce(firstInspection.promise) + .mockReturnValueOnce(secondInspection.promise) + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess, + dispatchCompletion, + isLive: () => true + }) + + coordinator.startProcessTracking() + vi.advanceTimersByTime(2_000) + await flushAsyncTicks() + coordinator.resetCompletionState({ requireFreshWorking: true }) + coordinator.observeTitle('⠋ experimental-agent-observability') + coordinator.observeTitle('experimental-agent-observability') + firstInspection.resolve(processResult('codex')) + await flushAsyncTicks() + vi.advanceTimersByTime(2_000) + await flushAsyncTicks() + secondInspection.resolve(processResult('codex')) + await flushAsyncTicks() + + expect(inspectProcess).toHaveBeenCalledTimes(2) + expect(dispatchCompletion).toHaveBeenCalledWith('experimental-agent-observability') + }) + + it('allows later done-only hook completions from the same long-lived process', () => { + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + + coordinator.observeHookStatus({ + state: 'done', + prompt: 'first task', + agentType: 'codex' + }) + coordinator.observeHookStatus({ + state: 'done', + prompt: 'first task', + agentType: 'codex' + }) + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(1_000) + coordinator.observeHookStatus({ + state: 'done', + prompt: 'second task', + agentType: 'codex' + }) + + expect(dispatchCompletion).toHaveBeenCalledTimes(2) + }) + + it.each([ + 'claude', + 'codex', + 'gemini', + 'opencode', + 'cursor', + 'pi', + 'droid', + 'grok', + 'copilot', + 'hermes' + ])('recognizes %s hook agent ids even when the binary name differs', (agentType) => { + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + + coordinator.observeHookStatus({ + state: 'done', + prompt: '', + agentType + }) + + expect(dispatchCompletion).toHaveBeenCalledWith(agentType) + }) + + it('keeps a generic title completion pending long enough for the first remote inspection', async () => { + const inspection = createDeferred() + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'remote:terminal-1', + getSettings: () => ({ activeRuntimeEnvironmentId: 'env-1' }), + inspectProcess: vi.fn(() => inspection.promise), + dispatchCompletion, + isLive: () => true + }) + + coordinator.observeTitle('⠋ experimental-agent-observability') + coordinator.observeTitle('experimental-agent-observability') + vi.advanceTimersByTime(10_500) + inspection.resolve(processResult('codex')) + await flushAsyncTicks() + + expect(dispatchCompletion).toHaveBeenCalledWith('experimental-agent-observability') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts new file mode 100644 index 00000000000..f7b9a2ddf7d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts @@ -0,0 +1,388 @@ +/* oxlint-disable max-lines */ +import { detectAgentStatusFromTitle, type AgentStatus } from '../../../../shared/agent-detection' +import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' +import { + isRecognizedAgentType, + recognizeAgentProcess, + type RecognizedAgentProcess +} from '../../../../shared/agent-process-recognition' +import { + enqueueAgentProcessInspection, + type InspectionPriority +} from './agent-process-inspection-queue' +import type { + AgentCompletionCoordinator, + AgentCompletionCoordinatorOptions +} from './agent-completion-coordinator-types' +import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection' +import { + titleHasExplicitAgentIdentity, + titleIsInconclusiveNativeDroidTitle +} from './title-agent-identity' + +type CompletionSource = 'hook' | 'title' | 'process-exit' + +const IDLE_POLL_INTERVAL_MS = 2_000 +const ACTIVE_POLL_INTERVAL_MS = 750 +const INSPECTION_TIMEOUT_MS = 15_000 +const PENDING_TITLE_TTL_MS = Math.max(2_000, INSPECTION_TIMEOUT_MS + 500) +const PENDING_TITLE_MAX_TTL_MS = Math.max(30_000, PENDING_TITLE_TTL_MS) +const COMPLETION_REPLAY_GUARD_MS = 1_000 + +function isCompletionHookState(state: ParsedAgentStatusPayload['state']): boolean { + return state === 'done' || state === 'waiting' || state === 'blocked' +} + +export function createAgentCompletionCoordinator( + options: AgentCompletionCoordinatorOptions +): AgentCompletionCoordinator { + let disposed = false + let agentIdentityEstablished = false + let hasAgentRunEvidence = false + let workingStatusObserved = false + let lastTitleStatus: AgentStatus | null = null + let currentTurn = 0 + let processSession = 0 + let lastCompletionToken: string | null = null + let lastCompletionAt = 0 + let lastCompletedTurn: number | null = null + let lastCompletionSource: CompletionSource | null = null + let lastForegroundAgent: RecognizedAgentProcess | null = null + let requiresFreshWorking = false + let pollTimer: ReturnType | null = null + let pendingTitleTimer: ReturnType | null = null + let pendingTitle: { + title: string + expiresAt: number + maxExpiresAt: number + firstInspectionFinished: boolean + } | null = null + let inspectionInFlight = false + let inspectionGeneration = 0 + let consecutiveInspectionErrors = 0 + + function clearPollTimer(): void { + if (pollTimer === null) { + return + } + clearTimeout(pollTimer) + pollTimer = null + } + + function clearPendingTitleTimer(): void { + if (pendingTitleTimer === null) { + return + } + clearTimeout(pendingTitleTimer) + pendingTitleTimer = null + } + + function establishAgentEvidence(): void { + agentIdentityEstablished = true + hasAgentRunEvidence = true + dispatchPendingTitleIfEligible() + } + + function clearAgentRunEvidence(): void { + agentIdentityEstablished = false + hasAgentRunEvidence = false + workingStatusObserved = false + dropPendingTitle() + } + + function completionToken(source: CompletionSource): string { + if (workingStatusObserved) { + return `turn:${currentTurn}` + } + if (lastForegroundAgent) { + return `process:${processSession}` + } + return `${source}:${currentTurn}:${processSession}` + } + + function dispatchCompletion(source: CompletionSource, title: string): void { + if (requiresFreshWorking || lastCompletedTurn === currentTurn) { + return + } + if (!options.isLive() || !hasAgentRunEvidence) { + return + } + const now = Date.now() + const token = completionToken(source) + if (token === lastCompletionToken && now - lastCompletionAt < COMPLETION_REPLAY_GUARD_MS) { + return + } + lastCompletionToken = token + lastCompletionAt = now + lastCompletedTurn = currentTurn + lastCompletionSource = source + workingStatusObserved = false + options.dispatchCompletion(title) + } + + function dropPendingTitle(): void { + clearPendingTitleTimer() + pendingTitle = null + } + + function dispatchPendingTitleIfEligible(): void { + if (!pendingTitle || !agentIdentityEstablished || !hasAgentRunEvidence) { + return + } + const title = pendingTitle.title + dropPendingTitle() + dispatchCompletion('title', title) + } + + function schedulePendingTitleExpiry(): void { + clearPendingTitleTimer() + const pending = pendingTitle + if (!pending) { + return + } + const remaining = pending.expiresAt - Date.now() + if (remaining <= 0) { + pendingTitle = null + scheduleNextPoll() + return + } + pendingTitleTimer = setTimeout(() => { + pendingTitleTimer = null + if (!pendingTitle) { + return + } + if (!pendingTitle.firstInspectionFinished && Date.now() < pendingTitle.maxExpiresAt) { + pendingTitle.expiresAt = Math.min(Date.now() + 500, pendingTitle.maxExpiresAt) + schedulePendingTitleExpiry() + return + } + pendingTitle = null + scheduleNextPoll() + }, remaining) + } + + function holdTitleCompletionPending(title: string): void { + const now = Date.now() + // Why: generic spinner titles can be just "⠋ cwd"; hold the completion + // only long enough for one foreground-process probe to prove an agent owns it. + pendingTitle = { + title, + expiresAt: Math.min(now + PENDING_TITLE_TTL_MS, now + PENDING_TITLE_MAX_TTL_MS), + maxExpiresAt: now + PENDING_TITLE_MAX_TTL_MS, + firstInspectionFinished: false + } + schedulePendingTitleExpiry() + requestInspection('pending-title') + } + + function handleRecognizedProcess(process: RecognizedAgentProcess): void { + if (lastForegroundAgent?.agent !== process.agent) { + if (lastForegroundAgent && hasAgentRunEvidence) { + dispatchCompletion('process-exit', lastForegroundAgent.processName) + } + processSession += 1 + } + lastForegroundAgent = process + establishAgentEvidence() + } + + function handleProcessInspectionResult(result: RuntimeTerminalProcessInspection): void { + consecutiveInspectionErrors = 0 + const recognized = recognizeAgentProcess(result.foregroundProcess) + if (recognized) { + handleRecognizedProcess(recognized) + } else if (lastForegroundAgent && hasAgentRunEvidence) { + const exited = lastForegroundAgent + dispatchCompletion('process-exit', exited.processName) + lastForegroundAgent = null + clearAgentRunEvidence() + } else { + lastForegroundAgent = null + clearAgentRunEvidence() + } + } + + function requestInspection(priority: InspectionPriority): void { + if (disposed || inspectionInFlight || !options.isLive()) { + return + } + const ptyId = options.getPtyId() + if (!ptyId) { + return + } + inspectionInFlight = true + const generationAtRequest = inspectionGeneration + enqueueAgentProcessInspection({ + priority, + run: async () => { + try { + const result = await options.inspectProcess(options.getSettings(), ptyId) + if (!disposed && generationAtRequest === inspectionGeneration) { + handleProcessInspectionResult(result) + } + } catch { + consecutiveInspectionErrors += 1 + } finally { + inspectionInFlight = false + if (generationAtRequest !== inspectionGeneration) { + if (pendingTitle) { + requestInspection('pending-title') + } else { + scheduleNextPoll() + } + } else { + if (pendingTitle) { + pendingTitle.firstInspectionFinished = true + dispatchPendingTitleIfEligible() + schedulePendingTitleExpiry() + } + scheduleNextPoll() + } + } + } + }) + } + + function nextPollInterval(): number { + const base = lastForegroundAgent ? ACTIVE_POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS + const backoff = + consecutiveInspectionErrors > 0 + ? Math.min(10_000, base * 2 ** consecutiveInspectionErrors) + : base + const jitter = 1 + (Math.random() * 0.2 - 0.1) + return Math.round(backoff * jitter) + } + + function scheduleNextPoll(): void { + if (disposed || !options.isLive() || pollTimer !== null || pendingTitle) { + return + } + const ptyId = options.getPtyId() + if (!ptyId) { + return + } + pollTimer = setTimeout(() => { + pollTimer = null + requestInspection('cadence') + }, nextPollInterval()) + } + + function recordTitleWorking(): boolean { + if ( + lastCompletionSource === 'hook' && + Date.now() - lastCompletionAt < COMPLETION_REPLAY_GUARD_MS + ) { + return false + } + workingStatusObserved = true + requiresFreshWorking = false + currentTurn += 1 + dropPendingTitle() + return true + } + + function observeTitleWorking(): void { + recordTitleWorking() + } + + function observeTitle(title: string): void { + const status = detectAgentStatusFromTitle(title) + const isInconclusiveNativeDroidTitle = titleIsInconclusiveNativeDroidTitle(title) + if (titleHasExplicitAgentIdentity(title) && !isInconclusiveNativeDroidTitle) { + establishAgentEvidence() + } + + if (status === 'working') { + if (!recordTitleWorking()) { + return + } + } else if (lastTitleStatus === 'working') { + if (isInconclusiveNativeDroidTitle) { + lastTitleStatus = status + return + } + if (agentIdentityEstablished && hasAgentRunEvidence) { + dispatchCompletion('title', title) + } else { + holdTitleCompletionPending(title) + } + } + lastTitleStatus = status + } + + function observeClassifiedTitleCompletion(title: string): void { + if (titleHasExplicitAgentIdentity(title)) { + establishAgentEvidence() + } + if (agentIdentityEstablished && hasAgentRunEvidence) { + dispatchCompletion('title', title) + } else { + holdTitleCompletionPending(title) + } + } + + function observeHookStatus(payload: ParsedAgentStatusPayload): void { + if (isRecognizedAgentType(payload.agentType)) { + establishAgentEvidence() + } + if (payload.state === 'working') { + workingStatusObserved = true + requiresFreshWorking = false + currentTurn += 1 + dropPendingTitle() + return + } + if (isCompletionHookState(payload.state)) { + if (isRecognizedAgentType(payload.agentType)) { + establishAgentEvidence() + } + if ( + !workingStatusObserved && + lastCompletionSource === 'hook' && + lastCompletedTurn === currentTurn && + Date.now() - lastCompletionAt >= COMPLETION_REPLAY_GUARD_MS + ) { + // Why: some hook producers only emit terminal states. Treat later + // done-only hook completions as new turns without letting title/process + // backstops duplicate the same completion. + currentTurn += 1 + } + dispatchCompletion('hook', payload.agentType ?? options.paneKey) + } + } + + function startProcessTracking(): void { + scheduleNextPoll() + } + + function resetCompletionState(options: { requireFreshWorking?: boolean } = {}): void { + dropPendingTitle() + agentIdentityEstablished = false + hasAgentRunEvidence = false + workingStatusObserved = false + lastTitleStatus = null + lastCompletionToken = null + lastCompletionAt = 0 + lastCompletedTurn = null + lastCompletionSource = null + lastForegroundAgent = null + requiresFreshWorking = options.requireFreshWorking ?? false + inspectionGeneration += 1 + } + + function dispose(): void { + disposed = true + clearPollTimer() + dropPendingTitle() + } + + return { + observeTitle, + observeClassifiedTitleCompletion, + observeTitleWorking, + observeHookStatus, + startProcessTracking, + resetCompletionState, + dispose + } +} diff --git a/src/renderer/src/components/terminal-pane/agent-process-inspection-queue.ts b/src/renderer/src/components/terminal-pane/agent-process-inspection-queue.ts new file mode 100644 index 00000000000..797407b69a3 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/agent-process-inspection-queue.ts @@ -0,0 +1,80 @@ +export type InspectionPriority = 'cadence' | 'pending-title' + +type InspectionTask = { + priority: InspectionPriority + run: () => Promise +} + +const MAX_CONCURRENT_INSPECTIONS = 4 +const MAX_INSPECTION_STARTS_PER_SECOND = 8 + +let activeInspections = 0 +let inspectionPumpTimer: ReturnType | null = null +const inspectionStarts: number[] = [] +const inspectionQueue: InspectionTask[] = [] + +function canStartInspection(now: number): boolean { + if (inspectionStarts.length > 0 && now < inspectionStarts[0]!) { + inspectionStarts.length = 0 + } + while (inspectionStarts.length > 0 && now - inspectionStarts[0]! >= 1_000) { + inspectionStarts.shift() + } + return ( + activeInspections < MAX_CONCURRENT_INSPECTIONS && + inspectionStarts.length < MAX_INSPECTION_STARTS_PER_SECOND + ) +} + +function scheduleInspectionPump(delayMs = 0): void { + if (inspectionPumpTimer !== null) { + return + } + inspectionPumpTimer = setTimeout(() => { + inspectionPumpTimer = null + pumpInspectionQueue() + }, delayMs) +} + +function pumpInspectionQueue(): void { + const now = Date.now() + if (!canStartInspection(now)) { + scheduleInspectionPump(100) + return + } + + const priorityIndex = inspectionQueue.findIndex((task) => task.priority === 'pending-title') + const next = + priorityIndex >= 0 ? inspectionQueue.splice(priorityIndex, 1)[0] : inspectionQueue.shift() + if (!next) { + return + } + + activeInspections += 1 + inspectionStarts.push(now) + void next.run().finally(() => { + activeInspections = Math.max(0, activeInspections - 1) + if (inspectionQueue.length > 0) { + scheduleInspectionPump() + } + }) + + if (inspectionQueue.length > 0) { + scheduleInspectionPump() + } +} + +export function enqueueAgentProcessInspection(task: InspectionTask): void { + inspectionQueue.push(task) + pumpInspectionQueue() +} + +export function resetAgentProcessInspectionQueueForTests(): void { + if (inspectionPumpTimer !== null) { + clearTimeout(inspectionPumpTimer) + inspectionPumpTimer = null + } + activeInspections = 0 + inspectionStarts.length = 0 + inspectionQueue.length = 0 +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 7b7b209db72..060f6ea8252 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -63,6 +63,7 @@ type StoreState = { consumePendingColdRestore: ReturnType consumePendingSnapshot: ReturnType agentStatusByPaneKey: Record + setAgentStatus: ReturnType removeAgentStatus: ReturnType dropAgentStatus: ReturnType } @@ -308,6 +309,15 @@ describe('connectPanePty', () => { consumePendingColdRestore: vi.fn(() => null), consumePendingSnapshot: vi.fn(() => null), agentStatusByPaneKey: {}, + setAgentStatus: vi.fn((paneKey: string, payload: Record) => { + mockStoreState.agentStatusByPaneKey[paneKey] = { + ...payload, + paneKey, + updatedAt: Date.now(), + stateStartedAt: Date.now(), + stateHistory: [] + } + }), removeAgentStatus: vi.fn(), dropAgentStatus: vi.fn() } as StoreState @@ -319,6 +329,8 @@ describe('connectPanePty', () => { }, pty: { signal: vi.fn(), + getForegroundProcess: vi.fn().mockResolvedValue(null), + hasChildProcesses: vi.fn().mockResolvedValue(false), ackColdRestore: vi.fn(), onClearBufferRequest: vi.fn(() => vi.fn()), onSerializeBufferRequest: vi.fn(() => vi.fn()), @@ -1333,6 +1345,480 @@ describe('connectPanePty', () => { ) }) + it('does not dispatch generic title completions when agent-complete notifications are disabled', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-codex') + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + enabled: true, + agentTaskComplete: false, + terminalBell: true, + suppressWhenFocused: false, + customSoundPath: null + } + } + const api = ( + globalThis as unknown as { + window: { api: { pty: { getForegroundProcess: ReturnType } } } + } + ).window.api + api.pty.getForegroundProcess.mockResolvedValue('codex') + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const titleHandler = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!titleHandler) { + throw new Error('Expected onTitleChange to be registered') + } + + titleHandler('⠋ experimental-agent-observability', '⠋ experimental-agent-observability') + titleHandler('experimental-agent-observability', 'experimental-agent-observability') + await flushAsyncTicks() + vi.advanceTimersByTime(1_000) + + expect(deps.dispatchNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + }) + + it('does not replay disabled generic title completions after notifications are re-enabled', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-codex') + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + enabled: true, + agentTaskComplete: false, + terminalBell: true, + suppressWhenFocused: false, + customSoundPath: null + } + } + const inspection = createDeferred() + const api = ( + globalThis as unknown as { + window: { api: { pty: { getForegroundProcess: ReturnType } } } + } + ).window.api + api.pty.getForegroundProcess.mockReturnValue(inspection.promise) + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const titleHandler = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!titleHandler) { + throw new Error('Expected onTitleChange to be registered') + } + + titleHandler('⠋ experimental-agent-observability', '⠋ experimental-agent-observability') + titleHandler('experimental-agent-observability', 'experimental-agent-observability') + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + ...mockStoreState.settings.notifications, + agentTaskComplete: true + } + } + inspection.resolve('codex') + await flushAsyncTicks() + vi.advanceTimersByTime(1_000) + + expect(deps.dispatchNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + }) + + it('clears title completion state when notifications are disabled', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-codex') + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false, + customSoundPath: null + } + } + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const titleHandler = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!titleHandler) { + throw new Error('Expected onTitleChange to be registered') + } + + titleHandler('Claude working', 'Claude working') + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + ...mockStoreState.settings.notifications, + agentTaskComplete: false + } + } + notifyStoreSubscribers() + titleHandler('Claude done', 'Claude done') + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + ...mockStoreState.settings.notifications, + agentTaskComplete: true + } + } + notifyStoreSubscribers() + titleHandler('Claude done', 'Claude done') + vi.advanceTimersByTime(1_000) + + expect(deps.dispatchNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + }) + + it('cancels scheduled agent completion when notifications are disabled before dispatch', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-codex') + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false, + customSoundPath: null + } + } + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as + | ((title: string) => void) + | undefined + if (!idleHandler) { + throw new Error('Expected onAgentBecameIdle to be registered') + } + + idleHandler('* Codex done') + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + ...mockStoreState.settings.notifications, + agentTaskComplete: false + } + } + notifyStoreSubscribers() + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + ...mockStoreState.settings.notifications, + agentTaskComplete: true + } + } + notifyStoreSubscribers() + vi.advanceTimersByTime(1_000) + + expect(deps.dispatchNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + }) + + it('restores a suppressed terminal bell when disabling pending agent completion', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-codex') + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false, + customSoundPath: null + } + } + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const bellHandler = createdTransportOptions[0]?.onBell as (() => void) | undefined + const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as + | ((title: string) => void) + | undefined + if (!bellHandler || !idleHandler) { + throw new Error('Expected bell and idle handlers to be registered') + } + + bellHandler() + idleHandler('* Codex done') + vi.advanceTimersByTime(250) + expect(deps.dispatchNotification).not.toHaveBeenCalledWith({ source: 'terminal-bell' }) + + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + ...mockStoreState.settings.notifications, + agentTaskComplete: false + } + } + notifyStoreSubscribers() + vi.advanceTimersByTime(250) + + expect(deps.dispatchNotification).toHaveBeenCalledWith({ source: 'terminal-bell' }) + }) + + it('requires fresh working evidence after notifications are disabled', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-codex') + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false, + customSoundPath: null + } + } + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const workingHandler = createdTransportOptions[0]?.onAgentBecameWorking as + | (() => void) + | undefined + const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as + | ((title: string) => void) + | undefined + if (!workingHandler || !idleHandler) { + throw new Error('Expected working and idle handlers to be registered') + } + + workingHandler() + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + ...mockStoreState.settings.notifications, + agentTaskComplete: false + } + } + notifyStoreSubscribers() + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + ...mockStoreState.settings.notifications, + agentTaskComplete: true + } + } + notifyStoreSubscribers() + idleHandler('* Codex done') + vi.advanceTimersByTime(1_000) + + expect(deps.dispatchNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + }) + + it('requires fresh working evidence when notifications start disabled then re-enable', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-codex') + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + enabled: true, + agentTaskComplete: false, + terminalBell: true, + suppressWhenFocused: false, + customSoundPath: null + } + } + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const workingHandler = createdTransportOptions[0]?.onAgentBecameWorking as + | (() => void) + | undefined + const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as + | ((title: string) => void) + | undefined + if (!workingHandler || !idleHandler) { + throw new Error('Expected working and idle handlers to be registered') + } + + workingHandler() + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + ...mockStoreState.settings.notifications, + agentTaskComplete: true + } + } + notifyStoreSubscribers() + idleHandler('* Codex done') + vi.advanceTimersByTime(1_000) + + expect(deps.dispatchNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + }) + + it('dispatches agent-task-complete for generic Codex spinner titles after process identity is confirmed', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-codex') + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + const api = ( + globalThis as unknown as { + window: { api: { pty: { getForegroundProcess: ReturnType } } } + } + ).window.api + api.pty.getForegroundProcess.mockResolvedValue('codex') + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const titleHandler = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!titleHandler) { + throw new Error('Expected onTitleChange to be registered') + } + + titleHandler('⠋ experimental-agent-observability', '⠋ experimental-agent-observability') + titleHandler('experimental-agent-observability', 'experimental-agent-observability') + await flushAsyncTicks() + + vi.advanceTimersByTime(1000) + + expect(deps.dispatchNotification).toHaveBeenCalledWith({ + source: 'agent-task-complete', + terminalTitle: 'experimental-agent-observability', + paneKey: makePaneKey('tab-1', LEAF_1) + }) + }) + + it('does not dispatch generic spinner completions when process inspection finds no agent', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-shell') + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + const api = ( + globalThis as unknown as { + window: { api: { pty: { getForegroundProcess: ReturnType } } } + } + ).window.api + api.pty.getForegroundProcess.mockResolvedValue('zsh') + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const titleHandler = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!titleHandler) { + throw new Error('Expected onTitleChange to be registered') + } + + titleHandler('⠋ experimental-agent-observability', '⠋ experimental-agent-observability') + titleHandler('experimental-agent-observability', 'experimental-agent-observability') + await flushAsyncTicks() + + vi.advanceTimersByTime(16_000) + + expect(deps.dispatchNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + }) + + it('dispatches agent-task-complete from recognized hook completion events', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-hook') + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const statusHandler = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { + state: 'done' + prompt: string + agentType: 'codex' + lastAssistantMessage: string + }) => void) + | undefined + if (!statusHandler) { + throw new Error('Expected onAgentStatus to be registered') + } + + statusHandler({ + state: 'done', + prompt: 'finish the implementation', + agentType: 'codex', + lastAssistantMessage: 'Done.' + }) + vi.advanceTimersByTime(250) + + expect(deps.dispatchNotification).toHaveBeenCalledWith({ + source: 'agent-task-complete', + terminalTitle: 'codex', + paneKey: makePaneKey('tab-1', LEAF_1) + }) + }) + it('restores a suppressed terminal bell when the pending agent completion is canceled', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index c0a0a2c724a..8a89b9b9dc9 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -18,6 +18,7 @@ import { POST_REPLAY_MODE_RESET, POST_REPLAY_FOCUS_REPORTING_RESET } from './lay import { warnTerminalLifecycleAnomaly } from './terminal-lifecycle-diagnostics' import { registerPtySerializer, registerPtyTitleSource } from './pty-buffer-serializer' import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' +import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' import { discardTerminalOutput, flushTerminalOutput, @@ -29,6 +30,7 @@ import { createTerminalCommandLifecycle } from './terminal-command-lifecycle' import { e2eConfig } from '@/lib/e2e-config' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { isWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id' +import { createAgentCompletionCoordinator } from './agent-completion-coordinator' const pendingSpawnByPaneKey = new Map>() const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' @@ -167,6 +169,9 @@ export function connectPanePty( let agentTaskCompleteNotificationGraceTimer: ReturnType | null = null let agentTaskCompleteNotificationMaxTimer: ReturnType | null = null let agentTaskCompleteStatusUnsubscribe: (() => void) | null = null + let agentTaskCompleteSettingsUnsubscribe: (() => void) | null = null + let agentTaskCompleteNotificationGeneration = 0 + let wasAgentTaskCompleteNotificationEnabled = isAgentTaskCompleteNotificationEnabled() let terminalBellNotificationTimer: ReturnType | null = null let pendingTerminalBellNotification = false // Why: passphrase-gate waits register a teardown here so dispose() can @@ -202,7 +207,25 @@ export function connectPanePty( }) commandLifecycle.attachXtermConsumer(pane.terminal) + const agentCompletionCoordinator = createAgentCompletionCoordinator({ + paneKey: cacheKey, + getPtyId: () => transport.getPtyId(), + getSettings: () => useAppStore.getState().settings, + inspectProcess: inspectRuntimeTerminalProcess, + dispatchCompletion: (title) => scheduleAgentTaskCompleteNotification(title), + isLive: () => { + if (disposed) { + return false + } + if (transport.getPtyId()) { + return true + } + return (useAppStore.getState().ptyIdsByTabId[deps.tabId] ?? []).length > 0 + } + }) + const onExit = (ptyId: string): void => { + agentCompletionCoordinator.dispose() deps.syncPanePtyLayoutBinding(pane.id, null) deps.clearRuntimePaneTitle(deps.tabId, pane.id) deps.clearTabPtyId(deps.tabId, ptyId) @@ -245,6 +268,9 @@ export function connectPanePty( const onTitleChange = (title: string, rawTitle: string): void => { manager.setPaneGpuRendering(pane.id, !isGeminiTerminalTitle(rawTitle)) deps.setRuntimePaneTitle(deps.tabId, pane.id, title) + if (syncAgentTaskCompleteNotificationEnabled()) { + agentCompletionCoordinator.observeTitle(rawTitle) + } // Why: only the focused pane should drive the tab title — otherwise two // agents in split panes cause rapid title flickering as each emits OSC // sequences. Only the active split's title propagates to the tab. When @@ -278,6 +304,7 @@ export function connectPanePty( // Spawn completion is when a pane gains a concrete PTY ID. The initial // frame-level sync often runs before that async result arrives. scheduleRuntimeGraphSync() + agentCompletionCoordinator.startProcessTracking() } // ─── Attention signal: BEL ──────────────────────────────────────────── // @@ -357,12 +384,43 @@ export function connectPanePty( } } + const syncAgentTaskCompleteNotificationEnabled = (): boolean => { + const enabled = isAgentTaskCompleteNotificationEnabled() + if (!enabled && wasAgentTaskCompleteNotificationEnabled) { + // Why: disabling notifications is an event-time boundary. Drop pending + // timers and coordinator state so completions observed while off cannot + // replay if the user turns the setting back on. + agentTaskCompleteNotificationGeneration += 1 + clearPendingAgentTaskCompleteNotification() + agentCompletionCoordinator.resetCompletionState({ requireFreshWorking: true }) + if (pendingTerminalBellNotification) { + scheduleTerminalBellNotification() + } + } else if (enabled && !wasAgentTaskCompleteNotificationEnabled) { + // Why: a pane may have observed work while agent-complete was disabled. + // Re-enabling should not let the next idle event notify for that old task. + agentCompletionCoordinator.resetCompletionState({ requireFreshWorking: true }) + } + wasAgentTaskCompleteNotificationEnabled = enabled + return enabled + } + const scheduleAgentTaskCompleteNotification = (title: string): void => { + if (!syncAgentTaskCompleteNotificationEnabled()) { + return + } clearPendingAgentTaskCompleteNotification() let graceElapsed = false + const generationAtSchedule = agentTaskCompleteNotificationGeneration const dispatch = (): void => { clearPendingAgentTaskCompleteNotification() + if ( + generationAtSchedule !== agentTaskCompleteNotificationGeneration || + !syncAgentTaskCompleteNotificationEnabled() + ) { + return + } pendingTerminalBellNotification = false clearTerminalBellNotificationTimer() if (disposed) { @@ -398,6 +456,9 @@ export function connectPanePty( AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS ) } + agentTaskCompleteSettingsUnsubscribe = useAppStore.subscribe(() => { + syncAgentTaskCompleteNotificationEnabled() + }) // ─── Agent task-complete: OS notification, not tab attention ────────── // @@ -429,18 +490,14 @@ export function connectPanePty( if (isClaudeAgent(title) && (settings === null || settings.promptCacheTimerEnabled)) { deps.setCacheTimerStartedAt(cacheKey, Date.now()) } - // Why: this is the sole producer of 'agent-task-complete' in the renderer; - // removing it (as #944 did) leaves the user-facing Settings toggle with no - // events to fire. Dispatch is gated per-source in main; the main-process - // dedupe also collapses concurrent BEL + task-complete for the same - // worktree into a single notification. - // Why: title idle can beat the final hook status update by one event-loop - // turn; delay slightly so the notification can snapshot the richer status. - if (isAgentTaskCompleteNotificationEnabled()) { - scheduleAgentTaskCompleteNotification(title) + if (syncAgentTaskCompleteNotificationEnabled()) { + agentCompletionCoordinator.observeClassifiedTitleCompletion(title) } } const onAgentBecameWorking = (): void => { + if (syncAgentTaskCompleteNotificationEnabled()) { + agentCompletionCoordinator.observeTitleWorking() + } // Why: a new API call refreshes the prompt-cache TTL, so clear any running // countdown. The timer will restart when the agent becomes idle again. deps.setCacheTimerStartedAt(cacheKey, null) @@ -524,6 +581,9 @@ export function connectPanePty( const currentState = useAppStore.getState() const title = currentState.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] currentState.setAgentStatus(cacheKey, payload, title) + if (syncAgentTaskCompleteNotificationEnabled()) { + agentCompletionCoordinator.observeHookStatus(payload) + } } } const transport = runtimeEnvironmentId @@ -883,6 +943,7 @@ export function connectPanePty( pane.container.dataset.ptyId = ptyId deps.syncPanePtyLayoutBinding(pane.id, ptyId) deps.updateTabPtyId(deps.tabId, ptyId) + agentCompletionCoordinator.startProcessTracking() // Why: mobile terminal streaming needs the exact screen state from // xterm.js. The shared helper installs both the SerializeAddon-backed @@ -1356,6 +1417,7 @@ export function connectPanePty( }) deps.syncPanePtyLayoutBinding(pane.id, attachPtyId) deps.updateTabPtyId(deps.tabId, attachPtyId) + agentCompletionCoordinator.startProcessTracking() } catch (err) { reportError(err instanceof Error ? err.message : String(err)) deps.clearTabPtyId(deps.tabId, attachPtyId) @@ -1403,6 +1465,9 @@ export function connectPanePty( onError: reportError } }) + // Why: attach sets the transport's PTY id; starting process + // tracking before this point no-ops because getPtyId() is empty. + agentCompletionCoordinator.startProcessTracking() }) .catch((err) => { reportError(err instanceof Error ? err.message : String(err)) @@ -1433,6 +1498,10 @@ export function connectPanePty( pendingTerminalBellNotification = false clearTerminalBellNotificationTimer() discardTerminalOutput(pane.terminal) + if (agentTaskCompleteSettingsUnsubscribe !== null) { + agentTaskCompleteSettingsUnsubscribe() + agentTaskCompleteSettingsUnsubscribe = null + } if (connectFrame !== null) { // Why: StrictMode and split-group remounts can dispose a pane binding // before its deferred PTY attach/spawn work runs. Cancel that queued @@ -1449,6 +1518,7 @@ export function connectPanePty( pendingGeometryReportRaf = null } commandLifecycle.dispose() + agentCompletionCoordinator.dispose() } } } diff --git a/src/renderer/src/components/terminal-pane/title-agent-identity.ts b/src/renderer/src/components/terminal-pane/title-agent-identity.ts new file mode 100644 index 00000000000..82006f71a84 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/title-agent-identity.ts @@ -0,0 +1,28 @@ +import { + detectAgentStatusFromTitle, + isGeminiTerminalTitle, + isPiTerminalTitle +} from '../../../../shared/agent-detection' + +const TITLE_AGENT_TOKEN_RE = + /(? 1, + terminalTitle: event.terminalTitle, + isActiveWorktree: state.activeWorktreeId === worktreeId, + ...agentSnapshot + }) + .then((result) => { + if (result.delivered) { + void playDesktopNotificationSound(customSoundPath) + } + }) + .catch((err) => { + console.warn('Failed to dispatch notification:', err) + }) +} + export function useNotificationDispatch( worktreeId: string ): (event: TerminalNotificationEvent) => void { return useCallback( - (event: TerminalNotificationEvent) => { - const state = useAppStore.getState() - - // Why: shutdownWorktreeTerminals clears ptyIdsByTabId synchronously - // before killing PTYs asynchronously. Any notification arriving after - // that point is stale — e.g. a staleTitleTimer that fires 3 s after - // shutdown, or an agent tracker transition from accumulated closure - // state. Checking for live PTYs at dispatch time catches ALL phantom - // notification sources regardless of which timer or callback produced - // them, rather than trying to cancel each one individually. - if (!hasLivePtyForWorktree(state, worktreeId)) { - return - } - - // Why: prefer worktree.repoId over string-parsing the worktreeId. The - // `${repoId}::${path}` format is an implementation detail of id - // construction; coupling the notification dispatcher to it would silently - // drop the repo label if that format ever changes. The worktree object - // itself is the source of truth for its owning repo. - const worktree = getWorktreeMapFromState(state).get(worktreeId) - const repo = worktree ? getRepoMapFromState(state).get(worktree.repoId) : null - const customSoundPath = state.settings?.notifications?.customSoundPath ?? null - const agentStatus = - event.source === 'agent-task-complete' && event.paneKey - ? state.agentStatusByPaneKey[event.paneKey] - : undefined - // Why: pane keys are reused across turns. A rich OS notification must not - // expose the previous turn's prompt if the current turn has no fresh hook snapshot yet. - const hasFreshAgentStatus = - agentStatus && Date.now() - agentStatus.updatedAt <= AGENT_NOTIFICATION_SNAPSHOT_MAX_AGE_MS - const agentSnapshot = hasFreshAgentStatus - ? { - agentType: agentStatus.agentType, - agentState: agentStatus.state, - agentPrompt: agentStatus.prompt, - agentToolName: agentStatus.toolName, - agentToolInput: agentStatus.toolInput, - agentLastAssistantMessage: agentStatus.lastAssistantMessage, - agentInterrupted: agentStatus.interrupted - } - : {} - - void window.api.notifications - .dispatch({ - source: event.source, - worktreeId, - repoLabel: repo?.displayName, - worktreeLabel: worktree?.displayName || worktree?.branch || worktreeId, - hasMultipleActiveRepos: countReposNeedingNotificationDisambiguation(state) > 1, - terminalTitle: event.terminalTitle, - isActiveWorktree: state.activeWorktreeId === worktreeId, - ...agentSnapshot - }) - .then((result) => { - if (result.delivered) { - void playDesktopNotificationSound(customSoundPath) - } - }) - .catch((err) => { - console.warn('Failed to dispatch notification:', err) - }) - }, + (event: TerminalNotificationEvent) => dispatchTerminalNotification(worktreeId, event), [worktreeId] ) } diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts new file mode 100644 index 00000000000..3d517ad9083 --- /dev/null +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' + +const dispatchTerminalNotification = vi.fn() + +type MockStoreState = { + settings: { + notifications: { + enabled: boolean + agentTaskComplete: boolean + } + } + ptyIdsByTabId: Record +} + +let mockStoreState: MockStoreState + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => mockStoreState + } +})) + +vi.mock('@/components/terminal-pane/use-notification-dispatch', () => ({ + dispatchTerminalNotification +})) + +function hookStatus(state: ParsedAgentStatusPayload['state']): ParsedAgentStatusPayload { + return { + state, + prompt: 'implement notifications', + agentType: 'codex', + lastAssistantMessage: state === 'done' ? 'Done.' : undefined + } +} + +describe('agent hook completion notifications', () => { + const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111' + + beforeEach(() => { + vi.resetModules() + dispatchTerminalNotification.mockClear() + mockStoreState = { + settings: { + notifications: { + enabled: true, + agentTaskComplete: true + } + }, + ptyIdsByTabId: { + 'tab-1': ['pty-1'] + } + } + }) + + it('requires fresh working after notifications start disabled and later re-enable', async () => { + mockStoreState.settings.notifications.agentTaskComplete = false + const { + observeAgentHookCompletionForNotification, + syncAgentHookCompletionNotificationSettings + } = await import('./agent-hook-completion-notifications') + + mockStoreState.settings.notifications.agentTaskComplete = true + syncAgentHookCompletionNotificationSettings() + + observeAgentHookCompletionForNotification({ + paneKey, + worktreeId: 'wt-1', + payload: hookStatus('done') + }) + + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + + observeAgentHookCompletionForNotification({ + paneKey, + worktreeId: 'wt-1', + payload: hookStatus('working') + }) + observeAgentHookCompletionForNotification({ + paneKey, + worktreeId: 'wt-1', + payload: hookStatus('done') + }) + + expect(dispatchTerminalNotification).toHaveBeenCalledWith( + 'wt-1', + expect.objectContaining({ + source: 'agent-task-complete', + paneKey + }) + ) + }) +}) diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.ts new file mode 100644 index 00000000000..dd080d8b44a --- /dev/null +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.ts @@ -0,0 +1,123 @@ +import { useAppStore } from '@/store' +import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import { createAgentCompletionCoordinator } from '@/components/terminal-pane/agent-completion-coordinator' +import type { AgentCompletionCoordinator } from '@/components/terminal-pane/agent-completion-coordinator-types' +import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection' +import { dispatchTerminalNotification } from '@/components/terminal-pane/use-notification-dispatch' + +type CoordinatorEntry = { + worktreeId: string + coordinator: AgentCompletionCoordinator +} + +const coordinatorsByPaneKey = new Map() +const paneKeysRequiringFreshWorking = new Set() +let wasAgentTaskCompleteNotificationEnabled = isAgentTaskCompleteNotificationEnabled() +let requireFreshWorkingForNewCoordinators = !wasAgentTaskCompleteNotificationEnabled + +function isAgentTaskCompleteNotificationEnabled(): boolean { + const notifications = useAppStore.getState().settings?.notifications + return notifications?.enabled !== false && notifications?.agentTaskComplete !== false +} + +export function syncAgentHookCompletionNotificationSettings(): boolean { + const enabled = isAgentTaskCompleteNotificationEnabled() + if (!enabled || (!wasAgentTaskCompleteNotificationEnabled && enabled)) { + requireFreshWorkingForNewCoordinators = true + for (const [paneKey, entry] of coordinatorsByPaneKey) { + paneKeysRequiringFreshWorking.add(paneKey) + entry.coordinator.resetCompletionState({ requireFreshWorking: true }) + } + } + wasAgentTaskCompleteNotificationEnabled = enabled + return enabled +} + +function getPtyIdForPaneKey(paneKey: string): string | null { + const parsed = parsePaneKey(paneKey) + if (!parsed) { + return null + } + return useAppStore.getState().ptyIdsByTabId?.[parsed.tabId]?.[0] ?? null +} + +function paneHasLivePty(paneKey: string): boolean { + return getPtyIdForPaneKey(paneKey) !== null +} + +function createCoordinator(paneKey: string, worktreeId: string): AgentCompletionCoordinator { + return createAgentCompletionCoordinator({ + paneKey, + getPtyId: () => getPtyIdForPaneKey(paneKey), + getSettings: () => useAppStore.getState().settings, + inspectProcess: async (): Promise => ({ + foregroundProcess: null, + hasChildProcesses: false + }), + dispatchCompletion: (title) => { + dispatchTerminalNotification(worktreeId, { + source: 'agent-task-complete', + terminalTitle: title, + paneKey + }) + }, + isLive: () => paneHasLivePty(paneKey) + }) +} + +export function observeAgentHookCompletionForNotification({ + paneKey, + worktreeId, + payload +}: { + paneKey: string + worktreeId: string + payload: ParsedAgentStatusPayload +}): void { + if (!paneHasLivePty(paneKey)) { + coordinatorsByPaneKey.get(paneKey)?.coordinator.dispose() + coordinatorsByPaneKey.delete(paneKey) + paneKeysRequiringFreshWorking.delete(paneKey) + return + } + + if (!syncAgentHookCompletionNotificationSettings()) { + paneKeysRequiringFreshWorking.add(paneKey) + coordinatorsByPaneKey + .get(paneKey) + ?.coordinator.resetCompletionState({ requireFreshWorking: true }) + return + } + + let entry = coordinatorsByPaneKey.get(paneKey) + if (!entry || entry.worktreeId !== worktreeId) { + entry?.coordinator.dispose() + entry = { + worktreeId, + coordinator: createCoordinator(paneKey, worktreeId) + } + coordinatorsByPaneKey.set(paneKey, entry) + if (requireFreshWorkingForNewCoordinators) { + paneKeysRequiringFreshWorking.add(paneKey) + } + } + if (paneKeysRequiringFreshWorking.has(paneKey)) { + entry.coordinator.resetCompletionState({ requireFreshWorking: true }) + } + + entry.coordinator.observeHookStatus(payload) + if (payload.state === 'working') { + paneKeysRequiringFreshWorking.delete(paneKey) + } +} + +export function resetAgentHookCompletionNotificationCoordinators(): void { + for (const entry of coordinatorsByPaneKey.values()) { + entry.coordinator.dispose() + } + coordinatorsByPaneKey.clear() + paneKeysRequiringFreshWorking.clear() + wasAgentTaskCompleteNotificationEnabled = isAgentTaskCompleteNotificationEnabled() + requireFreshWorkingForNewCoordinators = !wasAgentTaskCompleteNotificationEnabled +} diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index c91b5a764ab..2e5604b0ae8 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -67,6 +67,11 @@ import { createWebRuntimeSessionTerminal, isWebRuntimeSessionActive } from '@/runtime/web-runtime-session' +import { + observeAgentHookCompletionForNotification, + resetAgentHookCompletionNotificationCoordinators, + syncAgentHookCompletionNotificationSettings +} from './agent-hook-completion-notifications' export { resolveZoomTarget } from './resolve-zoom-target' @@ -1835,6 +1840,17 @@ export function useIpcEvents(): void { updatedAt: data.receivedAt, stateStartedAt: data.stateStartedAt }) + const statusWorktreeId = data.worktreeId ?? owningWorktreeId + if (options?.replay !== true && statusWorktreeId) { + // Why: local Codex/Claude hooks arrive through this main-process IPC + // path, not the PTY OSC fallback, so task-complete notifications must + // observe accepted hook state here as well. + observeAgentHookCompletionForNotification({ + paneKey: data.paneKey, + worktreeId: statusWorktreeId, + payload + }) + } } let snapshotRequestedForReadyWindow = false @@ -1927,7 +1943,12 @@ export function useIpcEvents(): void { // can be safely ignored instead of buffered against partially hydrated // renderer state. requestAgentStatusSnapshotIfReady() - unsubs.push(useAppStore.subscribe(() => requestAgentStatusSnapshotIfReady())) + unsubs.push( + useAppStore.subscribe(() => { + requestAgentStatusSnapshotIfReady() + syncAgentHookCompletionNotificationSettings() + }) + ) let mobileStateHydrated = isRuntimeEnvironmentActive() type PendingMobileStateEvent = @@ -2036,7 +2057,10 @@ export function useIpcEvents(): void { }) } - return () => unsubs.forEach((fn) => fn()) + return () => { + unsubs.forEach((fn) => fn()) + resetAgentHookCompletionNotificationCoordinators() + } }, []) } diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 7dbad8869ef..bc3df1a2b5a 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -312,6 +312,7 @@ export type UISlice = { | 'input' | 'tasks' | 'terminal' + | 'notifications' | 'computer-use' | 'developer-permissions' | 'shortcuts' diff --git a/src/shared/agent-hook-endpoint-file.test.ts b/src/shared/agent-hook-endpoint-file.test.ts new file mode 100644 index 00000000000..dfa928290ea --- /dev/null +++ b/src/shared/agent-hook-endpoint-file.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { isAgentHookEndpointFileName, parseAgentHookEndpointFile } from './agent-hook-endpoint-file' + +describe('agent hook endpoint files', () => { + it('recognizes POSIX and Windows endpoint file names', () => { + expect(isAgentHookEndpointFileName('endpoint.env')).toBe(true) + expect(isAgentHookEndpointFileName('endpoint.cmd')).toBe(true) + expect(isAgentHookEndpointFileName('endpoint.ps1')).toBe(false) + }) + + it('parses POSIX endpoint.env contents', () => { + expect( + parseAgentHookEndpointFile( + [ + 'ORCA_AGENT_HOOK_PORT=12345', + 'ORCA_AGENT_HOOK_TOKEN=token-123', + 'ORCA_AGENT_HOOK_ENV=production', + 'ORCA_AGENT_HOOK_VERSION=1' + ].join('\n') + ) + ).toEqual({ + port: '12345', + token: 'token-123', + env: 'production', + version: '1' + }) + }) + + it('parses Windows endpoint.cmd contents', () => { + expect( + parseAgentHookEndpointFile( + [ + 'set ORCA_AGENT_HOOK_PORT=54321', + 'set ORCA_AGENT_HOOK_TOKEN=token-abc', + 'set ORCA_AGENT_HOOK_ENV=development', + 'set ORCA_AGENT_HOOK_VERSION=1' + ].join('\r\n') + ) + ).toEqual({ + port: '54321', + token: 'token-abc', + env: 'development', + version: '1' + }) + }) + + it('preserves equals signs in endpoint values', () => { + expect( + parseAgentHookEndpointFile( + [ + 'ORCA_AGENT_HOOK_PORT=12345', + 'ORCA_AGENT_HOOK_TOKEN=token=with=equals', + 'ORCA_AGENT_HOOK_ENV=production', + 'ORCA_AGENT_HOOK_VERSION=1' + ].join('\n') + ).token + ).toBe('token=with=equals') + }) + + it('throws when required endpoint fields are missing', () => { + expect(() => parseAgentHookEndpointFile('ORCA_AGENT_HOOK_PORT=12345')).toThrow( + 'Agent hook endpoint file is missing required fields' + ) + }) +}) diff --git a/src/shared/agent-hook-endpoint-file.ts b/src/shared/agent-hook-endpoint-file.ts new file mode 100644 index 00000000000..6f9c1c9a717 --- /dev/null +++ b/src/shared/agent-hook-endpoint-file.ts @@ -0,0 +1,42 @@ +export const AGENT_HOOK_ENDPOINT_FILE_NAMES = ['endpoint.env', 'endpoint.cmd'] as const + +export type AgentHookEndpointFileName = (typeof AGENT_HOOK_ENDPOINT_FILE_NAMES)[number] + +export type AgentHookEndpoint = { + port: string + token: string + env: string + version: string +} + +export function isAgentHookEndpointFileName(name: string): name is AgentHookEndpointFileName { + return AGENT_HOOK_ENDPOINT_FILE_NAMES.some((fileName) => fileName === name) +} + +export function parseAgentHookEndpointFile(contents: string): AgentHookEndpoint { + const values = Object.fromEntries( + contents + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const normalizedLine = line.replace(/^set\s+/i, '') + const [key, ...rest] = normalizedLine.split('=') + return [key, rest.join('=')] + }) + ) + if ( + !values.ORCA_AGENT_HOOK_PORT || + !values.ORCA_AGENT_HOOK_TOKEN || + !values.ORCA_AGENT_HOOK_ENV || + !values.ORCA_AGENT_HOOK_VERSION + ) { + throw new Error('Agent hook endpoint file is missing required fields') + } + return { + port: values.ORCA_AGENT_HOOK_PORT, + token: values.ORCA_AGENT_HOOK_TOKEN, + env: values.ORCA_AGENT_HOOK_ENV, + version: values.ORCA_AGENT_HOOK_VERSION + } +} diff --git a/src/shared/agent-process-recognition.test.ts b/src/shared/agent-process-recognition.test.ts new file mode 100644 index 00000000000..5b9fb5dda5c --- /dev/null +++ b/src/shared/agent-process-recognition.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest' +import { isRecognizedAgentType, recognizeAgentProcess } from './agent-process-recognition' + +describe('agent process recognition', () => { + it('recognizes packaged Codex foreground process names', () => { + expect(recognizeAgentProcess('codex-aarch64-ap')).toEqual({ + agent: 'codex', + processName: 'codex-aarch64-ap' + }) + expect(isRecognizedAgentType('codex-aarch64-ap')).toBe(true) + }) +}) diff --git a/src/shared/agent-process-recognition.ts b/src/shared/agent-process-recognition.ts new file mode 100644 index 00000000000..a423b327c2f --- /dev/null +++ b/src/shared/agent-process-recognition.ts @@ -0,0 +1,77 @@ +import { TUI_AGENT_CONFIG } from './tui-agent-config' +import type { AgentType } from './agent-status-types' +import type { TuiAgent } from './types' + +export type RecognizedAgentProcess = { + agent: TuiAgent + processName: string +} + +const EXTENSION_RE = /\.(?:exe|cmd|bat|ps1)$/i + +function normalizeProcessName(processName: string | null | undefined): string { + if (!processName) { + return '' + } + const unquoted = processName.trim().replace(/^["']|["']$/g, '') + const basename = unquoted.split(/[\\/]/).pop() ?? unquoted + return basename.toLowerCase().replace(EXTENSION_RE, '') +} + +function firstCommandToken(command: string): string { + return command.trim().split(/\s+/)[0] ?? '' +} + +const PROCESS_TO_AGENT = new Map() +const AGENT_TYPE_IDS = new Set() + +for (const [agent, config] of Object.entries(TUI_AGENT_CONFIG) as [ + TuiAgent, + (typeof TUI_AGENT_CONFIG)[TuiAgent] +][]) { + AGENT_TYPE_IDS.add(agent) + for (const candidate of [ + config.expectedProcess, + config.detectCmd, + firstCommandToken(config.launchCmd) + ]) { + const normalized = normalizeProcessName(candidate) + if (normalized) { + PROCESS_TO_AGENT.set(normalized, agent) + } + } +} + +function agentForNormalizedProcess(normalized: string): TuiAgent | undefined { + const exact = PROCESS_TO_AGENT.get(normalized) + if (exact) { + return exact + } + // Why: node-pty can report Codex's packaged platform binary + // (for example codex-aarch64-ap) instead of the launch command. + if (normalized.startsWith('codex-')) { + return PROCESS_TO_AGENT.get('codex') + } + return undefined +} + +export function recognizeAgentProcess( + processName: string | null | undefined +): RecognizedAgentProcess | null { + const normalized = normalizeProcessName(processName) + const agent = agentForNormalizedProcess(normalized) + if (!agent) { + return null + } + return { agent, processName: normalized } +} + +export function isRecognizedAgentType(agentType: AgentType | null | undefined): boolean { + if (typeof agentType !== 'string') { + return false + } + return ( + AGENT_TYPE_IDS.has(agentType as TuiAgent) || + agentForNormalizedProcess(normalizeProcessName(agentType)) !== undefined + ) +} diff --git a/tests/e2e/droid-notification.spec.ts b/tests/e2e/droid-notification.spec.ts index 711c8b47c14..78374552753 100644 --- a/tests/e2e/droid-notification.spec.ts +++ b/tests/e2e/droid-notification.spec.ts @@ -8,10 +8,16 @@ import { waitForTerminalOutput } from './helpers/terminal' import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { emitCodexHookStatus, readHookEndpoint } from './helpers/agent-hook-endpoint' type NotificationDispatch = { source?: string terminalTitle?: string + paneKey?: string + isActiveWorktree?: boolean + agentType?: string + agentPrompt?: string + agentLastAssistantMessage?: string } async function emitOscTitle(page: Page, ptyId: string, title: string) { @@ -46,7 +52,205 @@ async function getNotificationDispatches( }) } +async function createAndSwitchToOtherWorktree(page: Page): Promise { + return page.evaluate(async () => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const state = store.getState() + const activeWorktreeId = state.activeWorktreeId + if (!activeWorktreeId) { + throw new Error('No active worktree') + } + const activeWorktree = Object.values(state.worktreesByRepo) + .flat() + .find((worktree) => worktree.id === activeWorktreeId) + if (!activeWorktree) { + throw new Error(`Active worktree ${activeWorktreeId} not found`) + } + const result = await state.createWorktree( + activeWorktree.repoId, + `codex-hook-notify-${Date.now()}` + ) + await state.fetchWorktrees(activeWorktree.repoId) + state.setActiveWorktree(result.worktree.id) + return result.worktree.id + }) +} + +async function getAgentStatuses(page: Page): Promise< + { + paneKey: string + state: string + agentType?: string + prompt?: string + lastAssistantMessage?: string + }[] +> { + return page.evaluate(() => { + const store = window.__store + if (!store) { + return [] + } + return Object.values(store.getState().agentStatusByPaneKey ?? {}).map((entry) => ({ + paneKey: entry.paneKey, + state: entry.state, + agentType: entry.agentType, + prompt: entry.prompt, + lastAssistantMessage: entry.lastAssistantMessage + })) + }) +} + +async function getActivePaneDescriptor( + page: Page +): Promise<{ paneKey: string; worktreeId: string }> { + return page.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const state = store.getState() + const worktreeId = state.activeWorktreeId + if (!worktreeId) { + throw new Error('No active worktree') + } + const tabId = state.activeTabIdByWorktree[worktreeId] ?? state.activeTabId + if (!tabId) { + throw new Error('No active tab') + } + const manager = window.__paneManagers?.get(tabId) + const activePane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] + const leafId = activePane ? manager?.getLeafIdMap?.().get(activePane.id) : null + if (!leafId) { + throw new Error('No active pane leaf id') + } + return { paneKey: `${tabId}:${leafId}`, worktreeId } + }) +} + test.describe('Droid notifications', () => { + test('Codex hook completion dispatches while its worktree is inactive', async ({ + orcaPage, + electronApp + }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await installMainProcessNotificationDispatchSpy(electronApp) + const endpoint = await readHookEndpoint(electronApp) + + const { paneKey, worktreeId } = await getActivePaneDescriptor(orcaPage) + const prompt = `codex-hook-notify-${Date.now()}` + await emitCodexHookStatus(endpoint, { + paneKey, + worktreeId, + state: 'working', + prompt + }) + await expect + .poll( + async () => + (await getAgentStatuses(orcaPage)).some( + (status) => + status.agentType === 'codex' && status.state === 'working' && status.prompt === prompt + ), + { + timeout: 10_000, + message: 'Codex UserPromptSubmit hook did not reach renderer agent status' + } + ) + .toBe(true) + + await createAndSwitchToOtherWorktree(orcaPage) + + const finalMessage = `Codex hook completed ${Date.now()}` + await emitCodexHookStatus(endpoint, { + paneKey, + worktreeId, + state: 'done', + prompt, + lastAssistantMessage: finalMessage + }) + await expect + .poll( + async () => + (await getAgentStatuses(orcaPage)).some( + (status) => + status.agentType === 'codex' && + status.state === 'done' && + status.prompt === prompt && + status.lastAssistantMessage === finalMessage + ), + { + timeout: 10_000, + message: 'Codex Stop hook did not reach renderer agent status' + } + ) + .toBe(true) + + await expect + .poll( + async () => { + const dispatches = await getNotificationDispatches(electronApp) + return dispatches.filter((dispatch) => dispatch.source === 'agent-task-complete') + }, + { + timeout: 10_000, + message: 'Codex hook Stop did not dispatch task-complete while worktree was inactive' + } + ) + .toEqual([ + expect.objectContaining({ + source: 'agent-task-complete', + terminalTitle: 'codex', + isActiveWorktree: false, + agentType: 'codex', + agentPrompt: prompt, + agentLastAssistantMessage: finalMessage + }) + ]) + }) + + test('recognized agent title completion dispatches one task-complete notification', async ({ + orcaPage, + electronApp + }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + // Why: contextBridge freezes window.api, so notification invokes must be + // observed in Electron's main process rather than monkey-patched renderer-side. + await installMainProcessNotificationDispatchSpy(electronApp) + await installRendererTitleLog(orcaPage) + + const ptyId = await waitForActivePanePtyId(orcaPage) + const marker = `__CODEX_NOTIFY_READY_${Date.now()}__` + await sendToTerminal(orcaPage, ptyId, `printf '${marker}\\n'\r`) + await waitForTerminalOutput(orcaPage, marker) + + await emitOscTitle(orcaPage, ptyId, 'Codex working') + await emitOscTitle(orcaPage, ptyId, 'Codex done') + + await expect + .poll( + async () => { + const dispatches = await getNotificationDispatches(electronApp) + return dispatches.filter((dispatch) => dispatch.source === 'agent-task-complete') + }, + { + timeout: 10_000, + message: 'Codex working->done title transition did not dispatch task-complete' + } + ) + .toEqual([ + expect.objectContaining({ source: 'agent-task-complete', terminalTitle: 'Codex done' }) + ]) + }) + test('Factory Droid needs-input native title does not dispatch a task-complete notification', async ({ orcaPage, electronApp diff --git a/tests/e2e/helpers/agent-hook-endpoint.ts b/tests/e2e/helpers/agent-hook-endpoint.ts new file mode 100644 index 00000000000..eefa42daeeb --- /dev/null +++ b/tests/e2e/helpers/agent-hook-endpoint.ts @@ -0,0 +1,79 @@ +import type { ElectronApplication } from '@stablyai/playwright-test' +import { existsSync, readdirSync, readFileSync } from 'fs' +import path from 'path' +import { + isAgentHookEndpointFileName, + parseAgentHookEndpointFile, + type AgentHookEndpoint +} from '../../../src/shared/agent-hook-endpoint-file' + +function findEndpointEnvFile(root: string): string | null { + if (!existsSync(root)) { + return null + } + const entries = readdirSync(root, { withFileTypes: true }) + for (const entry of entries) { + const fullPath = path.join(root, entry.name) + if (entry.isFile() && isAgentHookEndpointFileName(entry.name)) { + return fullPath + } + if (entry.isDirectory()) { + const nested = findEndpointEnvFile(fullPath) + if (nested) { + return nested + } + } + } + return null +} + +export async function readHookEndpoint(app: ElectronApplication): Promise { + const userDataPath = await app.evaluate(({ app: electronApp }) => electronApp.getPath('userData')) + const hookRoot = path.join(userDataPath, 'agent-hooks') + const endpointPath = findEndpointEnvFile(hookRoot) + if (!endpointPath) { + throw new Error(`Agent hook endpoint file not found under ${hookRoot}`) + } + return parseAgentHookEndpointFile(readFileSync(endpointPath, 'utf8')) +} + +export async function emitCodexHookStatus( + endpoint: AgentHookEndpoint, + status: { + paneKey: string + worktreeId: string + state: 'working' | 'done' + prompt?: string + lastAssistantMessage?: string + } +): Promise { + const [tabId] = status.paneKey.split(':') + const payload = + status.state === 'working' + ? { + hook_event_name: 'UserPromptSubmit', + prompt: status.prompt + } + : { + hook_event_name: 'Stop', + last_assistant_message: status.lastAssistantMessage + } + const response = await fetch(`http://127.0.0.1:${endpoint.port}/hook/codex`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': endpoint.token + }, + body: JSON.stringify({ + paneKey: status.paneKey, + tabId, + worktreeId: status.worktreeId, + env: endpoint.env, + version: endpoint.version, + payload + }) + }) + if (response.status !== 204) { + throw new Error(`Codex hook POST returned ${response.status}`) + } +} diff --git a/tests/e2e/notification-settings.spec.ts b/tests/e2e/notification-settings.spec.ts new file mode 100644 index 00000000000..ae33f26d7ae --- /dev/null +++ b/tests/e2e/notification-settings.spec.ts @@ -0,0 +1,94 @@ +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' +import type { GlobalSettings } from '../../src/shared/types' + +async function getSettings( + page: Parameters[0] +): Promise { + return page.evaluate(() => window.api.settings.get()) +} + +async function openNotificationSettings( + page: Parameters[0] +): Promise { + await page.evaluate(() => { + const state = window.__store!.getState() + state.openSettingsTarget({ pane: 'notifications', repoId: null }) + state.openSettingsPage() + }) + await expect(page.getByPlaceholder('Search settings')).toBeVisible({ timeout: 10_000 }) + const featureTipDialog = page.getByRole('dialog', { name: 'Voice Dictation is here' }) + if (await featureTipDialog.isVisible().catch(() => false)) { + await page.getByRole('button', { name: 'Maybe Later' }).click() + } + await expect( + page + .locator('[data-settings-section="notifications"]') + .getByRole('heading', { name: 'Notifications', exact: true }) + ).toBeInViewport({ timeout: 10_000 }) +} + +test.describe('Notification settings', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + }) + + test('can be toggled from settings and disables child controls', async ({ orcaPage }) => { + await openNotificationSettings(orcaPage) + + const notificationsSection = orcaPage.locator('[data-settings-section="notifications"]') + const enableNotificationsSwitch = notificationsSection.getByRole('switch', { + name: 'Enable Notifications' + }) + const agentTaskCompleteSwitch = notificationsSection.getByRole('switch', { + name: 'Agent Task Complete' + }) + const terminalBellSwitch = notificationsSection.getByRole('switch', { name: 'Terminal Bell' }) + const suppressWhileFocusedSwitch = notificationsSection.getByRole('switch', { + name: 'Suppress While Focused' + }) + const sendTestButton = notificationsSection.getByRole('button', { + name: 'Send Test Notification' + }) + + await expect(enableNotificationsSwitch).toHaveAttribute('aria-checked', 'true') + await expect(agentTaskCompleteSwitch).toBeEnabled() + await expect(terminalBellSwitch).toBeEnabled() + await expect(suppressWhileFocusedSwitch).toBeEnabled() + await expect(sendTestButton).toBeEnabled() + + await agentTaskCompleteSwitch.click() + await expect(agentTaskCompleteSwitch).toHaveAttribute('aria-checked', 'false') + await expect + .poll(async () => (await getSettings(orcaPage)).notifications.agentTaskComplete, { + timeout: 5_000, + message: 'agent task-complete notification setting did not persist after disabling' + }) + .toBe(false) + + await enableNotificationsSwitch.click() + await expect(enableNotificationsSwitch).toHaveAttribute('aria-checked', 'false') + await expect(agentTaskCompleteSwitch).toBeDisabled() + await expect(terminalBellSwitch).toBeDisabled() + await expect(suppressWhileFocusedSwitch).toBeDisabled() + await expect(sendTestButton).toBeDisabled() + await expect + .poll(async () => (await getSettings(orcaPage)).notifications.enabled, { + timeout: 5_000, + message: 'master notification setting did not persist after disabling' + }) + .toBe(false) + + await enableNotificationsSwitch.click() + await agentTaskCompleteSwitch.click() + await expect + .poll(async () => { + const settings = await getSettings(orcaPage) + return { + enabled: settings.notifications.enabled, + agentTaskComplete: settings.notifications.agentTaskComplete + } + }) + .toEqual({ enabled: true, agentTaskComplete: true }) + }) +})