mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
Fix agent completion notification dedupe (#2304)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -597,7 +597,11 @@ export class AgentHookServer {
|
||||
this.applyNormalizedStatus(event)
|
||||
}
|
||||
|
||||
async start(options?: { env?: string; userDataPath?: string }): Promise<void> {
|
||||
async start(options?: {
|
||||
env?: string
|
||||
userDataPath?: string
|
||||
endpointNamespace?: string
|
||||
}): Promise<void> {
|
||||
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
|
||||
}
|
||||
|
||||
+11
-7
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -80,6 +80,14 @@ const ptyPaneKey = new Map<string, string>()
|
||||
// Kept in lock-step with ptyPaneKey via the same spawn and teardown sites.
|
||||
const paneKeyPtyId = new Map<string, string>()
|
||||
|
||||
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
|
||||
|
||||
@@ -47,6 +47,25 @@ type NotificationsPaneProps = {
|
||||
updateSettings: (updates: Partial<GlobalSettings>) => 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<void> => {
|
||||
// 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<void> => {
|
||||
|
||||
@@ -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<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
|
||||
inspectProcess: (
|
||||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
|
||||
ptyId: string
|
||||
) => Promise<RuntimeTerminalProcessInspection>
|
||||
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
|
||||
}
|
||||
@@ -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<void> {
|
||||
for (let i = 0; i < count; i++) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
function processResult(foregroundProcess: string | null): RuntimeTerminalProcessInspection {
|
||||
return { foregroundProcess, hasChildProcesses: foregroundProcess !== null }
|
||||
}
|
||||
|
||||
function createDeferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolveDeferred!: (value: T) => void
|
||||
const promise = new Promise<T>((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<RuntimeTerminalProcessInspection>()
|
||||
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<RuntimeTerminalProcessInspection>()
|
||||
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<RuntimeTerminalProcessInspection>()
|
||||
const secondInspection = createDeferred<RuntimeTerminalProcessInspection>()
|
||||
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<RuntimeTerminalProcessInspection>()
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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<typeof setTimeout> | null = null
|
||||
let pendingTitleTimer: ReturnType<typeof setTimeout> | 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
export type InspectionPriority = 'cadence' | 'pending-title'
|
||||
|
||||
type InspectionTask = {
|
||||
priority: InspectionPriority
|
||||
run: () => Promise<void>
|
||||
}
|
||||
|
||||
const MAX_CONCURRENT_INSPECTIONS = 4
|
||||
const MAX_INSPECTION_STARTS_PER_SECOND = 8
|
||||
|
||||
let activeInspections = 0
|
||||
let inspectionPumpTimer: ReturnType<typeof setTimeout> | 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
|
||||
}
|
||||
@@ -63,6 +63,7 @@ type StoreState = {
|
||||
consumePendingColdRestore: ReturnType<typeof vi.fn>
|
||||
consumePendingSnapshot: ReturnType<typeof vi.fn>
|
||||
agentStatusByPaneKey: Record<string, unknown>
|
||||
setAgentStatus: ReturnType<typeof vi.fn>
|
||||
removeAgentStatus: ReturnType<typeof vi.fn>
|
||||
dropAgentStatus: ReturnType<typeof vi.fn>
|
||||
}
|
||||
@@ -308,6 +309,15 @@ describe('connectPanePty', () => {
|
||||
consumePendingColdRestore: vi.fn(() => null),
|
||||
consumePendingSnapshot: vi.fn(() => null),
|
||||
agentStatusByPaneKey: {},
|
||||
setAgentStatus: vi.fn((paneKey: string, payload: Record<string, unknown>) => {
|
||||
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<typeof vi.fn> } } }
|
||||
}
|
||||
).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<string | null>()
|
||||
const api = (
|
||||
globalThis as unknown as {
|
||||
window: { api: { pty: { getForegroundProcess: ReturnType<typeof vi.fn> } } }
|
||||
}
|
||||
).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<typeof vi.fn> } } }
|
||||
}
|
||||
).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<typeof vi.fn> } } }
|
||||
}
|
||||
).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()
|
||||
|
||||
@@ -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<string, Promise<string | null>>()
|
||||
const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED'
|
||||
@@ -167,6 +169,9 @@ export function connectPanePty(
|
||||
let agentTaskCompleteNotificationGraceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let agentTaskCompleteNotificationMaxTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let agentTaskCompleteStatusUnsubscribe: (() => void) | null = null
|
||||
let agentTaskCompleteSettingsUnsubscribe: (() => void) | null = null
|
||||
let agentTaskCompleteNotificationGeneration = 0
|
||||
let wasAgentTaskCompleteNotificationEnabled = isAgentTaskCompleteNotificationEnabled()
|
||||
let terminalBellNotificationTimer: ReturnType<typeof setTimeout> | 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
detectAgentStatusFromTitle,
|
||||
isGeminiTerminalTitle,
|
||||
isPiTerminalTitle
|
||||
} from '../../../../shared/agent-detection'
|
||||
|
||||
const TITLE_AGENT_TOKEN_RE =
|
||||
/(?<![\w./\\-])(claude|codex|gemini|opencode|openclaw|aider|copilot|cursor-agent|cursor|droid|hermes|grok|pi)(?![\w./\\-])/i
|
||||
|
||||
export function titleHasExplicitAgentIdentity(title: string): boolean {
|
||||
if (!title) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
title.startsWith('. ') ||
|
||||
title.startsWith('* ') ||
|
||||
title.startsWith('\u2733') ||
|
||||
isGeminiTerminalTitle(title) ||
|
||||
isPiTerminalTitle(title)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return TITLE_AGENT_TOKEN_RE.test(title)
|
||||
}
|
||||
|
||||
export function titleIsInconclusiveNativeDroidTitle(title: string): boolean {
|
||||
return /\bDroid\b/i.test(title) && detectAgentStatusFromTitle(title) === null
|
||||
}
|
||||
@@ -119,72 +119,77 @@ function countReposNeedingNotificationDisambiguation(
|
||||
* creates a new array reference on every store update and triggers
|
||||
* excessive re-renders of TerminalPane.
|
||||
*/
|
||||
export function dispatchTerminalNotification(
|
||||
worktreeId: string,
|
||||
event: TerminalNotificationEvent
|
||||
): void {
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
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]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<string, string[]>
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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<string, CoordinatorEntry>()
|
||||
const paneKeysRequiringFreshWorking = new Set<string>()
|
||||
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<RuntimeTerminalProcessInspection> => ({
|
||||
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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
||||
|
||||
@@ -312,6 +312,7 @@ export type UISlice = {
|
||||
| 'input'
|
||||
| 'tasks'
|
||||
| 'terminal'
|
||||
| 'notifications'
|
||||
| 'computer-use'
|
||||
| 'developer-permissions'
|
||||
| 'shortcuts'
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<string, TuiAgent>()
|
||||
const AGENT_TYPE_IDS = new Set<TuiAgent>()
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
@@ -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<string> {
|
||||
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
|
||||
|
||||
@@ -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<AgentHookEndpoint> {
|
||||
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<void> {
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
@@ -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<typeof waitForSessionReady>[0]
|
||||
): Promise<GlobalSettings> {
|
||||
return page.evaluate(() => window.api.settings.get())
|
||||
}
|
||||
|
||||
async function openNotificationSettings(
|
||||
page: Parameters<typeof waitForSessionReady>[0]
|
||||
): Promise<void> {
|
||||
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 })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user