diff --git a/src/main/agent-hooks/server-turn-ended-at.test.ts b/src/main/agent-hooks/server-turn-ended-at.test.ts new file mode 100644 index 00000000000..abaf0867270 --- /dev/null +++ b/src/main/agent-hooks/server-turn-ended-at.test.ts @@ -0,0 +1,271 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { AgentHookServer, _internals } from './server' +import { buildBody, postHookEvent, PANE } from './server.test-fixtures' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) })) + +// The execution host labels each turn end by what it can prove now: an identified end (an unused +// opening or a new turn id) or revision (a verified re-entered Stop) mints; a proven repeat keeps +// the end; anything else is ambiguous, never mints, and leaves main's title behavior to the renderer. +describe('the host-stamped turn end', () => { + let server: AgentHookServer + let directory: string + + beforeEach(async () => { + _internals.resetCachesForTests() + directory = mkdtempSync(join(tmpdir(), 'orca-turn-ended-at-')) + server = new AgentHookServer() + await server.start({ env: 'production', userDataPath: directory }) + }) + + afterEach(() => { + server.stop() + rmSync(directory, { recursive: true, force: true }) + }) + + async function post(payload: Record, path = '/hook/claude'): Promise { + expect((await postHookEvent(server, buildBody(payload), path)).status).toBe(204) + // Distinct host stamps: each event lands on a later millisecond. + await new Promise((resolve) => setTimeout(resolve, 5)) + } + const row = () => server.getStatusSnapshot()[0] + + it('mints identified ends and revisions; an unproven repeat or a lost opening is ambiguous', async () => { + await post({ hook_event_name: 'UserPromptSubmit', prompt: 'Lint the repo' }) + await post({ hook_event_name: 'Stop', last_assistant_message: 'Done.' }) + const first = row() + expect(first).toMatchObject({ turnEndDisposition: 'identified' }) + + // Without a turn id, a repeat can't be told from a turn whose prompt hook never arrived. + await post({ hook_event_name: 'Stop', last_assistant_message: 'Done.' }) + await post({ hook_event_name: 'Notification', notification_type: 'idle_prompt' }) + expect(row()).toMatchObject({ + turnEndedAt: first?.turnEndedAt, + turnEndDisposition: 'ambiguous' + }) + + // A blocking Stop hook made Claude continue; the same text still ends a new attempt. + await post({ hook_event_name: 'Stop', stop_hook_active: true, last_assistant_message: 'Done.' }) + expect(row()?.turnEndedAt).toBeGreaterThan(first!.turnEndedAt!) + expect(row()).toMatchObject({ turnEndDisposition: 'identified' }) + expect(row()?.stateStartedAt).toBe(first?.stateStartedAt) + + // The next prompt's hook was lost: its tool work and Stop reuse no earlier end. + await post({ hook_event_name: 'PreToolUse', tool_name: 'Bash' }) + await post({ hook_event_name: 'Stop', last_assistant_message: 'Second.' }) + expect(row()).toMatchObject({ state: 'done', turnEndDisposition: 'ambiguous' }) + expect(row()).not.toHaveProperty('turnEndedAt') + + // A cancel Orca inferred from Ctrl+C ends the turn, so the turn's late Stop is no second end. + await post({ hook_event_name: 'UserPromptSubmit', prompt: 'Fix it' }) + const working = row()! + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: working.receivedAt, + baselineStateStartedAt: working.stateStartedAt, + baselinePrompt: working.prompt, + baselineAgentType: 'claude', + intent: 'ctrl-c' + }) + ).toBe(true) + const cancelled = row() + await post({ hook_event_name: 'Stop', last_assistant_message: 'Fixed.' }) + expect(row()).toMatchObject({ + state: 'done', + stateStartedAt: cancelled?.stateStartedAt, + turnEndDisposition: 'ambiguous' + }) + expect(row()).not.toHaveProperty('turnEndedAt') + }) + + it('keeps a Grok end across a session reset and the idle reminder that follows it', async () => { + const turn = { sessionId: 'session-1', promptId: 'prompt-1' } + await post({ hookEventName: 'user_prompt_submit', ...turn, prompt: 'go' }, '/hook/grok') + await post({ hookEventName: 'stop', ...turn, lastAssistantMessage: 'Shipped.' }, '/hook/grok') + const ended = row()?.turnEndedAt + expect(ended).toEqual(expect.any(Number)) + + await post({ hookEventName: 'session_start', sessionId: 'session-2' }, '/hook/grok') + await post( + { hookEventName: 'notification', sessionId: 'session-2', notificationType: 'idle_prompt' }, + '/hook/grok' + ) + expect(row()).toMatchObject({ + state: 'done', + turnEndedAt: ended, + turnEndDisposition: 'identified' + }) + }) + + it('ends Codex turns by turn id, with or without a prompt hook in between', async () => { + const codex = (payload: Record) => post(payload, '/hook/codex') + await codex({ hook_event_name: 'UserPromptSubmit', turn_id: 'turn-a', prompt: 'Plan it' }) + await codex({ hook_event_name: 'Stop', turn_id: 'turn-a', last_assistant_message: 'Planned.' }) + const first = row()?.turnEndedAt + expect(first).toEqual(expect.any(Number)) + + // Its turn id and continuation flag prove an unflagged same-turn Stop is a duplicate, even + // after a tool reopen. + await codex({ hook_event_name: 'Stop', turn_id: 'turn-a', last_assistant_message: 'Planned.' }) + await codex({ hook_event_name: 'PreToolUse', turn_id: 'turn-a', tool_name: 'Bash' }) + await codex({ hook_event_name: 'Stop', turn_id: 'turn-a', last_assistant_message: 'Planned.' }) + expect(row()).toMatchObject({ + state: 'done', + turnEndedAt: first, + turnEndDisposition: 'identified' + }) + + // A turn woken by agent mail runs no prompt hook and stays `done`; its own turn id ends it. + await codex({ hook_event_name: 'Stop', turn_id: 'turn-b', last_assistant_message: 'Replied.' }) + const mailTurn = row()?.turnEndedAt + expect(mailTurn).toBeGreaterThan(first!) + + // Codex re-fires Stop with the flag when a blocking Stop hook continues the same turn. + await codex({ hook_event_name: 'Stop', turn_id: 'turn-b', stop_hook_active: true }) + expect(row()?.turnEndedAt).toBeGreaterThan(mailTurn!) + expect(row()).not.toHaveProperty('hostTurn') + }) + + it('ends Command Code turns by transcript position; a same-position repeat is ambiguous', async () => { + const transcriptPath = join(directory, 'command-code.jsonl') + const prompt = `${JSON.stringify({ role: 'user', content: 'Status?' })}\n` + const stop = { hook_event_name: 'Stop', transcript_path: transcriptPath } + writeFileSync(transcriptPath, prompt) + await post(stop, '/hook/command-code') + const first = row()?.turnEndedAt + expect(row()).toMatchObject({ turnEndDisposition: 'identified' }) + // Its key alone doesn't prove a repeat (no verified continuation flag). + await post(stop, '/hook/command-code') + expect(row()).toMatchObject({ turnEndedAt: first, turnEndDisposition: 'ambiguous' }) + + writeFileSync(transcriptPath, prompt.repeat(2)) + await post(stop, '/hook/command-code') + expect(row()?.turnEndedAt).toBeGreaterThan(first!) + expect(row()).toMatchObject({ turnEndDisposition: 'identified' }) + }) + + it('restates the end when a result retry adds the late transcript text', async () => { + const transcriptPath = join(directory, 'events.jsonl') + writeFileSync(transcriptPath, '') + await post({ hook_event_name: 'UserPromptSubmit', prompt: 'Fix it' }, '/hook/copilot') + // The Stop's text isn't flushed yet; its retry re-reads the same Stop body. + const stop = { hook_event_name: 'Stop', transcript_path: transcriptPath } + await post(stop, '/hook/copilot') + const ended = row()?.turnEndedAt + expect(row()).toMatchObject({ turnEndDisposition: 'identified' }) + + const line = { type: 'assistant.message', data: { content: 'Tests pass.' } } + writeFileSync(transcriptPath, `${JSON.stringify(line)}\n`) + await vi.waitFor(() => expect(row()?.lastAssistantMessage).toBe('Tests pass.')) + expect(row()).toMatchObject({ turnEndedAt: ended, turnEndDisposition: 'identified' }) + }) + + it('reaches the live push and survives a host restart, so a restatement stays the same end', async () => { + const pushed: (number | undefined)[] = [] + server.setListener((status) => pushed.push(status.turnEndedAt)) + const stop = { hook_event_name: 'Stop', turn_id: 'turn-a', last_assistant_message: 'Done.' } + await post(stop, '/hook/codex') + const ended = row()?.turnEndedAt + expect(pushed.at(-1)).toBe(ended) + server.flushStatusPersistSync() + server.stop() + + server = new AgentHookServer() + await server.start({ env: 'production', userDataPath: directory }) + expect(row()).toMatchObject({ turnEndedAt: ended, turnEndDisposition: 'identified' }) + // The saved turn record still knows turn-a ended, through a working reopen too. + await post( + { hook_event_name: 'PreToolUse', turn_id: 'turn-a', tool_name: 'Bash' }, + '/hook/codex' + ) + await post(stop, '/hook/codex') + expect(row()).toMatchObject({ state: 'done', turnEndedAt: ended }) + }) + + it("keeps a relay's own end, never mints an older relay's, and restates none over an inferred cancel", () => { + const grok = { paneKey: PANE, tabId: 'tab-1', source: 'grok' as const, hasExplicitPrompt: true } + const working = { + state: 'working' as const, + prompt: 'Fix it', + agentType: 'grok' as const, + mainAgent: { state: 'working' as const, stateStartedAt: 1 } + } + const stop = { ...grok, hookEventName: 'stop', payload: { ...working, state: 'done' as const } } + server.ingestRemote(stop, 'wsl-old') + expect(row()).not.toHaveProperty('turnEndedAt') + server.ingestRemote({ ...grok, hookEventName: 'user_prompt_submit', payload: working }, 'wsl') + server.ingestRemote({ ...stop, turnEndedAt: 42, turnEndDisposition: 'ambiguous' }, 'wsl') + expect(row()).toMatchObject({ turnEndedAt: 42, turnEndDisposition: 'ambiguous' }) + + // The relay never learns a non-Claude cancel inferred here, so its late cancel hook carries an end. + server.ingestRemote({ ...grok, hookEventName: 'user_prompt_submit', payload: working }, 'wsl') + const turn = row()! + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: turn.receivedAt, + baselineStateStartedAt: turn.stateStartedAt, + baselinePrompt: turn.prompt, + baselineAgentType: 'grok', + intent: 'ctrl-c' + }) + ).toBe(true) + const cancelled = row() + const mainAgent = { + state: 'done' as const, + outcome: 'cancellation' as const, + stateStartedAt: 2 + } + server.ingestRemote( + { + ...grok, + hookEventName: 'stop_cancelled', + turnEndedAt: 50, + turnEndDisposition: 'identified', + payload: { ...working, state: 'done', mainAgent } + }, + 'wsl' + ) + expect(row()).toMatchObject({ state: 'done', stateStartedAt: cancelled?.stateStartedAt }) + expect(row()).not.toHaveProperty('turnEndedAt') + + // A late plain stop carries no cancel, yet it and every relay restatement of it keep none. + const lateStop = { + ...stop, + turnEndedAt: 50, + turnEndDisposition: 'identified' as const, + payload: { + ...working, + state: 'done' as const, + mainAgent: { state: 'done' as const, stateStartedAt: 3 } + } + } + server.ingestRemote(lateStop, 'wsl') + server.ingestRemote( + { ...lateStop, payload: { ...lateStop.payload, lastAssistantMessage: 'Done.' } }, + 'wsl' + ) + server.ingestRemote({ ...lateStop, hookEventName: 'notification' }, 'wsl') + expect(row()).toMatchObject({ state: 'done', stateStartedAt: cancelled?.stateStartedAt }) + expect(row()).not.toHaveProperty('turnEndedAt') + + // The next turn reaches main as working first, so its end is the relay's again. + const next = { ...working, prompt: 'Again' } + server.ingestRemote({ ...grok, hookEventName: 'user_prompt_submit', payload: next }, 'wsl') + server.ingestRemote( + { + ...stop, + turnEndedAt: 60, + turnEndDisposition: 'identified', + payload: { ...next, state: 'done' } + }, + 'wsl' + ) + expect(row()).toMatchObject({ turnEndedAt: 60, turnEndDisposition: 'identified' }) + }) +}) diff --git a/src/main/agent-hooks/server/server-ingest-remote.ts b/src/main/agent-hooks/server/server-ingest-remote.ts index 709c8a539ec..3a01ba007c8 100644 --- a/src/main/agent-hooks/server/server-ingest-remote.ts +++ b/src/main/agent-hooks/server/server-ingest-remote.ts @@ -1,7 +1,11 @@ import { normalizeHostTurnRevision } from '../../../shared/agent-hook-interrupt-reconciliation' import { readAgentProcessPresence } from '../../../shared/agent-process-presence' import { track } from '../../telemetry/client' -import { normalizeAgentStatusPayload } from '../../../shared/agent-status-types' +import { + isTurnEndDisposition, + normalizeAgentStatusPayload +} from '../../../shared/agent-status-types' +import { isTurnEndedAt } from '../../../shared/agent-hook-turn-end-timing' import { restoreShedStatusFields } from '../../../shared/agent-hook-relay' import { MAX_PANE_KEY_LEN, @@ -279,6 +283,10 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS providerSession, providerSessionOnly: envelope.providerSessionOnly === true ? true : undefined, isReplay: envelope.isReplay === true ? true : undefined, + ...(isTurnEndedAt(envelope.turnEndedAt) ? { turnEndedAt: envelope.turnEndedAt } : {}), + ...(isTurnEndDisposition(envelope.turnEndDisposition) + ? { turnEndDisposition: envelope.turnEndDisposition } + : {}), claudeRunningNonAgentTask: typeof envelope.claudeRunningNonAgentTask === 'boolean' ? envelope.claudeRunningNonAgentTask diff --git a/src/main/agent-hooks/server/server-persistence-validation.ts b/src/main/agent-hooks/server/server-persistence-validation.ts index c4913e29c0e..c5187dfdd5e 100644 --- a/src/main/agent-hooks/server/server-persistence-validation.ts +++ b/src/main/agent-hooks/server/server-persistence-validation.ts @@ -3,11 +3,15 @@ import { createHash } from 'node:crypto' import { normalizeAgentProviderSession } from '../../../shared/agent-session-resume' import { + isTurnEndDisposition, normalizeAgentStatusPayload, type AgentMainAgentStatus, type ParsedAgentStatusPayload } from '../../../shared/agent-status-types' import { isAgentHookSource } from '../../../shared/agent-hook-relay' +import type { HostTurnRecord } from '../../../shared/agent-hook-listener/listener-event' +import { isTurnEndedAt } from '../../../shared/agent-hook-turn-end-timing' +import { isRecord } from '../../../shared/agent-status-child-work-value-guards' import { normalizeClaudePromptId, normalizeGrokPromptId @@ -151,6 +155,8 @@ export function sanitizeHydratedEntry( ? record.compactTrigger : undefined const turnStartedAt = record.turnStartedAt + const turnEndedAt = record.turnEndedAt + const hostTurn = readHostTurnRecord(record.hostTurn) return { paneKey, agentPresence: readAgentProcessPresence(record.agentPresence), @@ -176,10 +182,37 @@ export function sanitizeHydratedEntry( stateStartedAt, ...(typeof turnStartedAt === 'number' && Number.isFinite(turnStartedAt) && turnStartedAt > 0 ? { turnStartedAt } - : {}) + : {}), + ...(isTurnEndedAt(turnEndedAt) ? { turnEndedAt } : {}), + ...(isTurnEndDisposition(record.turnEndDisposition) + ? { turnEndDisposition: record.turnEndDisposition } + : {}), + ...(hostTurn ? { hostTurn } : {}) } } +function readHostTurnRecord(value: unknown): HostTurnRecord | undefined { + if (!isRecord(value)) { + return undefined + } + const { owner, currentTurnKey, completed } = value + if (typeof owner !== 'string' || typeof currentTurnKey !== 'string') { + return undefined + } + if (completed === undefined) { + return { owner, currentTurnKey } + } + return isRecord(completed) && + typeof completed.turnKey === 'string' && + isTurnEndedAt(completed.endedAt) + ? { + owner, + currentTurnKey, + completed: { turnKey: completed.turnKey, endedAt: completed.endedAt } + } + : undefined +} + export function readPersistedLaunchTokenHash(rawEntry: unknown): string | null { if (typeof rawEntry !== 'object' || rawEntry === null) { return null diff --git a/src/main/agent-hooks/server/server-remote-envelope-normalization.ts b/src/main/agent-hooks/server/server-remote-envelope-normalization.ts index cae060ad571..3211a7c30d6 100644 --- a/src/main/agent-hooks/server/server-remote-envelope-normalization.ts +++ b/src/main/agent-hooks/server/server-remote-envelope-normalization.ts @@ -33,6 +33,8 @@ export type RemoteAgentStatusEnvelope = { providerSession?: unknown providerSessionOnly?: unknown isReplay?: boolean + turnEndedAt?: unknown + turnEndDisposition?: unknown /** Payload fields the relay dropped to fit an oversized frame; validated below. */ shedFields?: unknown claudeRunningNonAgentTask?: unknown diff --git a/src/main/agent-hooks/server/server-status-application.ts b/src/main/agent-hooks/server/server-status-application.ts index 8d6d7e1372b..c636ef42231 100644 --- a/src/main/agent-hooks/server/server-status-application.ts +++ b/src/main/agent-hooks/server/server-status-application.ts @@ -5,6 +5,7 @@ import { getCohortAtEmit } from '../../telemetry/cohort-classifier' import { track } from '../../telemetry/client' import { isCommandCodeNewTurnWhileWorking } from '../../../shared/command-code-turn-boundary' import { isNewTurnEvent } from '../../../shared/agent-hook-listener/provider-event-routing' +import { reduceHostTurnEnd } from '../../../shared/agent-hook-turn-end-timing' import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' import type { AgentStatusObservation, @@ -157,14 +158,31 @@ export abstract class AgentHookServerStatusApplication extends AgentHookServerSt ? previous.stateStartedAt : (observedAt ?? now) const turnStartedAt = resolveTurnStartedAt(previous, payload, observedAt ?? now) + // Why: a relay's row was minted on the relay, which saw the event; main only restates it. + const { hostTurn, ...turnEnd } = reduceHostTurnEnd( + previous, + payload, + observedAt ?? now, + payload.connectionId === null + ) + const { + stopHookActive: _stopHookActive, + nativeTurnKey: _nativeTurnKey, + turnEndedAt: _turnEndedAt, + turnEndDisposition: _turnEndDisposition, + hostTurn: _hostTurn, + ...event + } = payload // Why: `stateStartedAt` tracks the current state, while `receivedAt` tracks every arrival. return { - ...payload, + ...event, receivedAt: now, evidenceObservedAt: observedAt ?? this.resolveEvidenceObservedAt(payload, previous, now), stateStartedAt, // Always written here, so a producer can never declare it. - turnStartedAt + turnStartedAt, + ...turnEnd, + ...(hostTurn ? { hostTurn } : {}) } } diff --git a/src/main/agent-hooks/server/server-status-identity.ts b/src/main/agent-hooks/server/server-status-identity.ts index 0e798e74230..6144fca8994 100644 --- a/src/main/agent-hooks/server/server-status-identity.ts +++ b/src/main/agent-hooks/server/server-status-identity.ts @@ -4,7 +4,11 @@ import { type AgentProviderSessionMetadata } from '../../../shared/agent-session-resume' import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../shared/stable-pane-id' -import type { AgentStatusIpcPayload, AgentType } from '../../../shared/agent-status-types' +import { + pickHostTurnEnd, + type AgentStatusIpcPayload, + type AgentType +} from '../../../shared/agent-status-types' import type { EnrichedAgentHookEventPayload } from './server-types' import { AGENT_PROMPT_SENT_AGENT_KINDS } from './server-constants' import { MAX_PANE_KEY_LEN } from '../../../shared/agent-hook-listener/listener-limits' @@ -61,6 +65,7 @@ export function toAgentStatusIpcPayload( : {}), stateStartedAt: entry.stateStartedAt, ...(entry.turnStartedAt !== undefined ? { turnStartedAt: entry.turnStartedAt } : {}), + ...pickHostTurnEnd(entry), ...(entry.providerSession ? { providerSession: entry.providerSession } : {}), ...(entry.providerSessionOnly ? { providerSessionOnly: true } : {}), ...(entry.promptInteractionKey ? { promptInteractionKey: entry.promptInteractionKey } : {}), diff --git a/src/main/agent-hooks/server/server-status-retries.ts b/src/main/agent-hooks/server/server-status-retries.ts index 19b4718d01d..40b6945b731 100644 --- a/src/main/agent-hooks/server/server-status-retries.ts +++ b/src/main/agent-hooks/server/server-status-retries.ts @@ -5,6 +5,7 @@ import { preparePendingGrokResultDiscovery } from '../../../shared/agent-hook-listener/grok-result-discovery' import type { AgentHookSource } from '../../../shared/agent-hook-relay' +import { pickHostTurnEnd } from '../../../shared/agent-status-types' import { shouldPollHookTranscript, hookTranscriptWatchPath, @@ -163,6 +164,10 @@ export abstract class AgentHookServerStatusRetries extends AgentHookServerStatus return } // Why: some agents POST Stop before their transcript line is flushed; discovery is event-driven, later content retries stay timed. - this.applyNormalizedStatus(normalized.event, normalized.onAccepted) + // The retry only adds text to the same turn end, so it restates that end instead of minting one. + this.applyNormalizedStatus( + { ...normalized.event, ...pickHostTurnEnd(current) }, + normalized.onAccepted + ) } } diff --git a/src/main/runtime/runtime-hook-agent-row-selection.ts b/src/main/runtime/runtime-hook-agent-row-selection.ts index fa889ad7815..0c675fee0cd 100644 --- a/src/main/runtime/runtime-hook-agent-row-selection.ts +++ b/src/main/runtime/runtime-hook-agent-row-selection.ts @@ -1,9 +1,11 @@ import { AGENT_STATUS_STALE_AFTER_MS, + pickHostTurnEnd, pickParsedAgentStatusPayload, type AgentStatusEntry, type AgentStatusIpcPayload, - type ParsedAgentStatusPayload + type ParsedAgentStatusPayload, + type TurnEndDisposition } from '../../shared/agent-status-types' import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume' import type { ProgramStatusFacet } from '../../shared/program-status/facet' @@ -18,6 +20,8 @@ export type RuntimeAgentRowSnapshot = { connectionId: string | null payload: ParsedAgentStatusPayload stateStartedAt: number + turnEndedAt?: number + turnEndDisposition?: TurnEndDisposition updatedAt: number evidenceObservedAt?: number providerSession?: AgentProviderSessionMetadata @@ -134,6 +138,7 @@ export function selectFreshAgentRowForMobileTab(args: { ...(match.tabId ? { tabId: match.tabId } : {}), payload: pickParsedAgentStatusPayload(match), stateStartedAt: match.stateStartedAt ?? match.receivedAt, + ...pickHostTurnEnd(match), updatedAt: match.receivedAt, ...(match.providerSession ? { providerSession: match.providerSession } : {}), ...(match.programStatus ? { programStatus: match.programStatus } : {}), diff --git a/src/main/runtime/runtime-mobile-agent-status-builder.ts b/src/main/runtime/runtime-mobile-agent-status-builder.ts index a8e0ff896ac..81b676093d1 100644 --- a/src/main/runtime/runtime-mobile-agent-status-builder.ts +++ b/src/main/runtime/runtime-mobile-agent-status-builder.ts @@ -3,7 +3,11 @@ import { normalizeCompatibleAgentTitleForOwner } from '../../shared/agent-title-owner' import { resolvePaneAgentOwnerRecord } from '../../shared/pane-agent-owner' -import type { AgentStatusEntry, AgentStatusIpcPayload } from '../../shared/agent-status-types' +import { + pickHostTurnEnd, + type AgentStatusEntry, + type AgentStatusIpcPayload +} from '../../shared/agent-status-types' import type { RuntimeMobileSessionTerminalTab } from '../../shared/runtime-types' import { renewRuntimeMobileAgentStatusFromPtyTitle, @@ -115,6 +119,7 @@ export function buildRuntimeMobileAgentStatus( ? { evidenceObservedAt: liveRow.evidenceObservedAt } : {}), stateStartedAt: liveRow.stateStartedAt, + ...pickHostTurnEnd(liveRow), stateHistory: [], ...(terminalHandle ? { terminalHandle } : {}), ...((pty?.worktreeId ?? liveRow.worktreeId) diff --git a/src/main/runtime/runtime-mobile-agent-status-projection.ts b/src/main/runtime/runtime-mobile-agent-status-projection.ts index f52585c747e..4ac6f32c7fe 100644 --- a/src/main/runtime/runtime-mobile-agent-status-projection.ts +++ b/src/main/runtime/runtime-mobile-agent-status-projection.ts @@ -2,6 +2,7 @@ import { AGENT_STATUS_HYDRATE_MAX_AGE_MS, AGENT_STATUS_STALE_AFTER_MS, agentStatusAuthorityObservedAt, + pickHostTurnEnd, pickParsedAgentStatusPayload, type AgentStatusEntry, type AgentStatusIpcPayload @@ -194,6 +195,7 @@ function toHookLiveAgentRow(row: AgentStatusIpcPayload): HookLiveAgentRow { updatedAt: row.receivedAt, ...(row.evidenceObservedAt !== undefined ? { evidenceObservedAt: row.evidenceObservedAt } : {}), stateStartedAt: row.stateStartedAt ?? row.receivedAt, + ...pickHostTurnEnd(row), ...(row.worktreeId ? { worktreeId: row.worktreeId } : {}), ...(row.programStatus ? { programStatus: row.programStatus } : {}) } diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index c640aa2d293..25d925df804 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -200,7 +200,14 @@ export type RuntimeTerminalProgramStatusFacetEvent = { export type HookLiveAgentRow = Pick< RuntimeAgentRowSnapshot, - 'payload' | 'updatedAt' | 'evidenceObservedAt' | 'stateStartedAt' | 'worktreeId' | 'programStatus' + | 'payload' + | 'updatedAt' + | 'evidenceObservedAt' + | 'stateStartedAt' + | 'turnEndedAt' + | 'turnEndDisposition' + | 'worktreeId' + | 'programStatus' > export type TerminalHandleRecord = { diff --git a/src/main/startup/main-window-agent-status.ts b/src/main/startup/main-window-agent-status.ts index fb4e94e2461..cbb314dbe92 100644 --- a/src/main/startup/main-window-agent-status.ts +++ b/src/main/startup/main-window-agent-status.ts @@ -4,6 +4,7 @@ import { setMigrationUnsupportedPtyListener } from '../agent-hooks/migration-uns import { getDashboardPopoutWindow } from '../window/dashboard-popout-window' import { stopSyntheticTitleSpinnerTimer } from './synthetic-title-runtime' import { mainProcessState as state } from './main-process-state' +import { pickHostTurnEnd } from '../../shared/agent-status-types' export type MainWindowAgentStatusOptions = { window: BrowserWindow @@ -22,6 +23,8 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt evidenceObservedAt, stateStartedAt, turnStartedAt, + turnEndedAt, + turnEndDisposition, launchToken, providerSession, providerSessionOnly, @@ -76,6 +79,7 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt ...(evidenceObservedAt !== undefined ? { evidenceObservedAt } : {}), stateStartedAt, ...(turnStartedAt !== undefined ? { turnStartedAt } : {}), + ...pickHostTurnEnd({ turnEndedAt, turnEndDisposition }), ...(providerSession ? { providerSession } : {}), ...(promptInteractionKey ? { promptInteractionKey } : {}), ...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), diff --git a/src/renderer/src/attention/agent-attention-notification-delivery.ts b/src/renderer/src/attention/agent-attention-notification-delivery.ts index 92ef6674b93..6d7e4d4fc46 100644 --- a/src/renderer/src/attention/agent-attention-notification-delivery.ts +++ b/src/renderer/src/attention/agent-attention-notification-delivery.ts @@ -12,7 +12,10 @@ */ import { playDesktopNotificationSound } from '@/lib/desktop-notification-sound' import { showBlockedNotificationFallbackToast } from '@/lib/blocked-notification-fallback' -import type { NotificationDispatchRequest } from '../../../shared/notification-settings-types' +import type { + NotificationDispatchRequest, + NotificationDispatchResult +} from '../../../shared/notification-settings-types' export type AgentAttentionNotificationSound = { customSoundId: string @@ -21,11 +24,13 @@ export type AgentAttentionNotificationSound = { export function deliverAgentAttentionNotification( request: NotificationDispatchRequest, - sound: AgentAttentionNotificationSound + sound: AgentAttentionNotificationSound, + onResult?: (result: NotificationDispatchResult | null) => void ): void { void window.api.notifications .dispatch(request) .then((result) => { + onResult?.(result) if (result.delivered) { void playDesktopNotificationSound(sound.customSoundId, sound.customSoundVolume) return @@ -37,6 +42,7 @@ export function deliverAgentAttentionNotification( } }) .catch((err) => { + onResult?.(null) console.warn('Failed to dispatch notification:', err) }) } diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts index 8dafd227610..c9f8663452f 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts @@ -1,9 +1,16 @@ -import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' +import type { + ParsedAgentStatusPayload, + TurnEndDisposition +} from '../../../../shared/agent-status-types' import type { RecognizedAgentProcess } from '../../../../shared/agent-process-recognition' import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection' export type AgentCompletionStatusSnapshot = ParsedAgentStatusPayload & { stateStartedAt?: number + /** The host's stamp for the turn end this `done` reports; restatements keep it. */ + turnEndedAt?: number + /** How the host identified that end (`TurnEndDisposition`). */ + turnEndDisposition?: TurnEndDisposition /** Renderer-local boundary used only to reject a delayed cross-host completion. */ localStateStartedAt?: number } @@ -42,6 +49,16 @@ export type AgentCompletionCoordinatorOptions = { /** Fired once per confirmed exit (settled local absence, or the host's `exited` verdict). */ onForegroundAgentExited?: (exited: RecognizedAgentProcess) => void isLive: () => boolean + /** How the hook lane identified the turn end a title edge (null: no title known) would announce: + * `identified` it announces it; `ambiguous` the title lane keeps main's role; null not hook-owned. */ + titleTurnEndEvidence?: ( + title: string | null, + foregroundAgent: RecognizedAgentProcess['agent'] | null + ) => 'identified' | 'ambiguous' | null + /** An idle edge whose turn end the hook lane announces still ends this output burst. */ + onHookOwnedTitleIdle?: () => void + /** Whether a hook snapshot's end is hook-owned on this pane (else main's behavior exactly). */ + usesHookTurnEnds?: (snapshot: AgentCompletionStatusSnapshot) => boolean shouldPollProcessCadence?: () => boolean // Why: a host that publishes foreground evidence with its inventory lets a // pane without agent evidence stay push-driven instead of scheduling @@ -67,6 +84,8 @@ export type AgentCompletionCoordinator = { /** Another reader saw this agent in the foreground; lets the monitor confirm its exit. */ observeForegroundAgentProcess: (process: RecognizedAgentProcess) => void hasPendingHookDoneCompletion: () => boolean + /** Whether a turn end observed now could be announced (live, with agent evidence, not re-arming). */ + canAnnounceCompletion: () => boolean resetCompletionState: (options?: { requireFreshWorking?: boolean }) => void dispose: () => void } diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts index 24df1dce1ae..a84e34fa870 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts @@ -10,6 +10,7 @@ import { getAgentCompletionCoordinatorIdentityCountForTest, resetAgentCompletionCoordinatorIdentitiesForTest } from './agent-completion-identity-store' +import { createTitleCompletionClaim } from './agent-completion-title-claim' import { createAgentCompletionProcessMonitor } from './agent-completion-process-monitor' import { createPendingTitleController } from './agent-completion-pending-title' import { @@ -60,6 +61,14 @@ export function createAgentCompletionCoordinator( lastForegroundAgent: null as RecognizedAgentProcess | null, processSession: 0 } + const titleClaim = createTitleCompletionClaim({ + options, + state: completionState, + processState, + identityScope, + titleAgentIdentity: (title) => titleObserver.titleCompletionAgentIdentity(title), + dispatchTitle: (title) => titleObserver.dispatchTitle(title) + }) let processMonitor!: ReturnType let pendingTitle!: ReturnType let notification!: ReturnType @@ -181,7 +190,7 @@ export function createAgentCompletionCoordinator( holdTitleCompletionPending, hasPendingTitle: () => pendingTitle.get() !== null, dropPendingTitle, - markTitleCompletionNotified, + claimTitleCompletion: (title) => titleClaim.claim(title), dispatchTitleCompletion: (title) => dispatchCompletion('title', title, { completionIdentity: { @@ -194,6 +203,7 @@ export function createAgentCompletionCoordinator( function dropPendingTitle(): void { pendingTitle.drop() + titleClaim.cancelOwnedIdle() } function dispatchPendingTitleIfEligible(): void { @@ -208,14 +218,7 @@ export function createAgentCompletionCoordinator( } const title = currentPendingTitle.title dropPendingTitle() - markTitleCompletionNotified(title) - dispatchCompletion('title', title, { - completionIdentity: { - source: 'title', - identity: title, - agentIdentity: titleObserver.titleCompletionAgentIdentity(title) - } - }) + titleObserver.dispatchTitle(title) } function holdTitleCompletionPending(title: string): void { @@ -230,7 +233,7 @@ export function createAgentCompletionCoordinator( recordPaneActivity() } - function recordTitleWorking(): boolean { + function recordTitleWorking(title: string | null): boolean { // Why: hooks can report `done` before title tracking notices the next milestone, so the working title must cancel that provisional done. clearPendingHookDone() if ( @@ -241,7 +244,8 @@ export function createAgentCompletionCoordinator( } completionState.workingStatusObserved = true completionState.requiresFreshWorking = false - if (!hasUnconsumedStampedTail()) { + // Why: the turn is tracked even on a hook-owned pane, in case its end turns out ambiguous. + if (!titleClaim.keepsHookReceipt(title) && !hasUnconsumedStampedTail()) { identityScope.deleteLast() } completionState.currentTurn += 1 @@ -250,15 +254,7 @@ export function createAgentCompletionCoordinator( } function observeTitleWorking(): void { - recordTitleWorking() - } - - function markTitleCompletionNotified(title: string): void { - completionState.lastCompletionIdentity = { - source: 'title', - identity: title, - agentIdentity: titleObserver.titleCompletionAgentIdentity(title) - } + recordTitleWorking(null) } hookObserver = createAgentCompletionHookObserver({ @@ -319,6 +315,7 @@ export function createAgentCompletionCoordinator( startProcessTracking: () => processMonitor.start(), observeForegroundAgentProcess: processMonitor.observeRecognizedProcess, hasPendingHookDoneCompletion: lifecycle.hasPendingHookDoneCompletion, + canAnnounceCompletion: notification.canAnnounceCompletion, resetCompletionState: lifecycle.resetCompletionState, dispose: lifecycle.dispose } diff --git a/src/renderer/src/components/terminal-pane/agent-completion-hook-observer.ts b/src/renderer/src/components/terminal-pane/agent-completion-hook-observer.ts index 698747a612e..cc086aef1a3 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-hook-observer.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-hook-observer.ts @@ -179,7 +179,8 @@ export function createAgentCompletionHookObserver({ !state.workingStatusObserved && state.lastCompletionSource === 'hook' && state.lastCompletedTurn === state.currentTurn && - Date.now() - state.lastCompletionAt >= 1_000 + // Why: a new host turn-end stamp is a real end even right after a banner; replays restate it. + (Date.now() - state.lastCompletionAt >= 1_000 || payload.turnEndedAt !== undefined) ) { state.currentTurn += 1 } diff --git a/src/renderer/src/components/terminal-pane/agent-completion-notification-controller.ts b/src/renderer/src/components/terminal-pane/agent-completion-notification-controller.ts index ea000e96749..83b04781871 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-notification-controller.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-notification-controller.ts @@ -73,9 +73,13 @@ export function createAgentCompletionNotificationController({ function hookCompletionIdentity(payload: AgentCompletionStatusSnapshot): string | null { // Why: `stateStartedAt` is pinned while the reported state does not change. A Claude pane held at `working` by background inventory would otherwise give every turn in the run the same identity. + // Why: `stateStartedAt` is also pinned across done→done, so a later turn end in one done + // segment is told from a restatement only by the host's turn-end stamp. const timestamp = isFiniteTurnCompletedAt(payload.turnCompletedAt) ? payload.turnCompletedAt - : payload.stateStartedAt + : payload.state === 'done' && isFiniteTurnCompletedAt(payload.turnEndedAt) + ? payload.turnEndedAt + : payload.stateStartedAt if (typeof timestamp !== 'number' || !Number.isFinite(timestamp)) { return null } @@ -142,6 +146,10 @@ export function createAgentCompletionNotificationController({ ) } + function canAnnounceCompletion(): boolean { + return !state.requiresFreshWorking && options.isLive() && processState.hasAgentRunEvidence + } + function dispatchCompletion( source: CompletionSource, title: string, @@ -157,10 +165,7 @@ export function createAgentCompletionNotificationController({ if (source !== 'hook' && state.pendingHookDoneTimer !== null) { return false } - if (state.requiresFreshWorking || state.lastCompletedTurn === state.currentTurn) { - return false - } - if (!options.isLive() || !processState.hasAgentRunEvidence) { + if (!canAnnounceCompletion() || state.lastCompletedTurn === state.currentTurn) { return false } const now = Date.now() @@ -204,8 +209,12 @@ export function createAgentCompletionNotificationController({ ...(optionsOverride.terminalIdleConfirmed === true ? { terminalIdleConfirmed: true } : {}), ...(optionsOverride.agentStatus ? { agentStatus: optionsOverride.agentStatus } : {}) }) - } else if (optionsOverride.notifyWithoutLifecycle === true && optionsOverride.agentStatus) { - // Why: the pane is still `working`; the synthetic done must carry its own snapshot or the notification would read the pinned working row. + } else if ( + optionsOverride.agentStatus && + (optionsOverride.notifyWithoutLifecycle === true || + options.usesHookTurnEnds?.(optionsOverride.agentStatus) === true) + ) { + // Why: carry the hook's own snapshot; the stored row may be a newer state (a pinned `working`) or too old to lend content. options.dispatchCompletion(title, { source, quietedHookDone: false, @@ -284,6 +293,7 @@ export function createAgentCompletionNotificationController({ dispatchCompletion, dispatchAttention, scheduleHookDoneCompletion, - hasPendingHookDone: () => state.pendingHookDoneTimer !== null + hasPendingHookDone: () => state.pendingHookDoneTimer !== null, + canAnnounceCompletion } } diff --git a/src/renderer/src/components/terminal-pane/agent-completion-snapshot-staleness.ts b/src/renderer/src/components/terminal-pane/agent-completion-snapshot-staleness.ts index 7b973c4a0be..22481fe50ce 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-snapshot-staleness.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-snapshot-staleness.ts @@ -2,7 +2,7 @@ import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import type { AgentCompletionStatusSnapshot } from './agent-completion-coordinator-types' export function isSupersededAgentCompletionSnapshot( - storedAgentStatus: Pick | undefined, + storedAgentStatus: Pick | undefined, snapshot: AgentCompletionStatusSnapshot | undefined ): boolean { if (!storedAgentStatus || !snapshot) { @@ -19,9 +19,12 @@ export function isSupersededAgentCompletionSnapshot( } const hasStampedTurn = typeof snapshot.turnCompletedAt === 'number' && Number.isFinite(snapshot.turnCompletedAt) - return ( - storedAgentStatus.stateStartedAt === comparableStateStartedAt && - storedAgentStatus.state !== snapshot.state && - !hasStampedTurn - ) + if (storedAgentStatus.stateStartedAt !== comparableStateStartedAt) { + return false + } + // Why: within one done segment, a later host turn end supersedes the delayed earlier one. + return storedAgentStatus.state !== snapshot.state + ? !hasStampedTurn + : snapshot.turnEndedAt !== undefined && + (storedAgentStatus.turnEndedAt ?? 0) > snapshot.turnEndedAt } diff --git a/src/renderer/src/components/terminal-pane/agent-completion-title-claim.ts b/src/renderer/src/components/terminal-pane/agent-completion-title-claim.ts new file mode 100644 index 00000000000..2a7822c5a0f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/agent-completion-title-claim.ts @@ -0,0 +1,86 @@ +import type { RecognizedAgentProcess } from '../../../../shared/agent-process-recognition' +import type { AgentCompletionCoordinatorOptions } from './agent-completion-coordinator-types' +import type { + AgentCompletionIdentityScope, + LastCompletionIdentity +} from './agent-completion-identity-store' +import type { CompletionState } from './agent-completion-notification-controller' +import { AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS } from './agent-task-complete-policy' + +type TitleClaimDeps = { + options: AgentCompletionCoordinatorOptions + state: CompletionState + processState: { lastForegroundAgent: RecognizedAgentProcess | null } + identityScope: AgentCompletionIdentityScope + titleAgentIdentity: (title: string) => string | null + dispatchTitle: (title: string) => void +} + +/** + * The title lane's claim on a pane's turn ends. Where the hook lane owns an identified end, a + * title working edge keeps the hook's receipt (a /btw or quit spin ends no hook turn) and the idle + * edge is not claimed; it settles after main's max wait unless the host meanwhile calls the end + * ambiguous (a turn whose opening hook was lost), which main's title completion then announces. + */ +export function createTitleCompletionClaim(deps: TitleClaimDeps) { + const { options, state, identityScope } = deps + let keptHookReceipt: LastCompletionIdentity | undefined + let ownedIdleTimer: ReturnType | null = null + + function evidence(title: string | null): 'identified' | 'ambiguous' | null { + const foreground = deps.processState.lastForegroundAgent?.agent ?? null + return options.titleTurnEndEvidence?.(title, foreground) ?? null + } + + function cancelOwnedIdle(): void { + if (ownedIdleTimer !== null) { + clearTimeout(ownedIdleTimer) + ownedIdleTimer = null + } + } + + function settleOwnedIdle(title: string): void { + cancelOwnedIdle() + const turn = state.currentTurn + ownedIdleTimer = setTimeout(() => { + ownedIdleTimer = null + if (state.currentTurn !== turn) { + return + } + if (evidence(title) === 'ambiguous') { + deps.dispatchTitle(title) + return + } + state.lastCompletedTurn = turn + state.workingStatusObserved = false + }, AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) + } + + return { + /** A working edge's turn: true when the hook's receipt is kept instead of deleted. */ + keepsHookReceipt(title: string | null): boolean { + keptHookReceipt = evidence(title) === 'identified' ? identityScope.getLast() : undefined + return keptHookReceipt !== undefined + }, + claim(title: string): boolean { + // Why: the hook lane announces this turn end; a claim would swallow its Done. + if (evidence(title) === 'identified') { + options.onHookOwnedTitleIdle?.() + settleOwnedIdle(title) + return false + } + // Why: once this turn's end is ambiguous, the receipt its working edge kept is obsolete. + if (keptHookReceipt && identityScope.getLast() === keptHookReceipt) { + identityScope.deleteLast() + } + keptHookReceipt = undefined + state.lastCompletionIdentity = { + source: 'title', + identity: title, + agentIdentity: deps.titleAgentIdentity(title) + } + return true + }, + cancelOwnedIdle + } +} diff --git a/src/renderer/src/components/terminal-pane/agent-completion-title-observer.ts b/src/renderer/src/components/terminal-pane/agent-completion-title-observer.ts index dbdcc21700f..bfd275ef01c 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-title-observer.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-title-observer.ts @@ -9,11 +9,11 @@ type TitleObserverOptions = { hasAgentEvidence: () => boolean establishAgentEvidence: () => void recordPaneActivity: () => void - recordTitleWorking: () => boolean + recordTitleWorking: (title: string) => boolean holdTitleCompletionPending: (title: string) => void hasPendingTitle: () => boolean dropPendingTitle: () => void - markTitleCompletionNotified: (title: string) => void + claimTitleCompletion: (title: string) => boolean dispatchTitleCompletion: (title: string) => void } @@ -27,7 +27,7 @@ export function createAgentCompletionTitleObserver({ holdTitleCompletionPending, hasPendingTitle, dropPendingTitle, - markTitleCompletionNotified, + claimTitleCompletion, dispatchTitleCompletion }: TitleObserverOptions) { function titleCompletionAgentIdentity(title: string): string | null { @@ -63,8 +63,9 @@ export function createAgentCompletionTitleObserver({ } function dispatchTitle(title: string): void { - markTitleCompletionNotified(title) - dispatchTitleCompletion(title) + if (claimTitleCompletion(title)) { + dispatchTitleCompletion(title) + } } function observeTitle(title: string): void { @@ -79,7 +80,7 @@ export function createAgentCompletionTitleObserver({ if (status === 'working') { // Why: a rejected working title is a post-hook-completion replay; leave lastStatus untouched so it can't open a completion branch. - if (!recordTitleWorking()) { + if (!recordTitleWorking(title)) { return } } else if (getLastStatus() === 'working') { @@ -116,6 +117,7 @@ export function createAgentCompletionTitleObserver({ } return { + dispatchTitle, observeTitle, observeClassifiedTitleCompletion, titleCompletionAgentIdentity diff --git a/src/renderer/src/components/terminal-pane/claude-side-question-completion.test.ts b/src/renderer/src/components/terminal-pane/claude-side-question-completion.test.ts new file mode 100644 index 00000000000..e3d571caece --- /dev/null +++ b/src/renderer/src/components/terminal-pane/claude-side-question-completion.test.ts @@ -0,0 +1,313 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + LEAF_1, + createMockTransport, + createPane, + createManager, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { connectMockPanePty } from './pty-connection-test-connect-mock-pane' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import type { AgentCompletionStatusSnapshot } from './agent-completion-coordinator-types' +import type { NotificationDispatchRequest } from '../../../../shared/notification-settings-types' +import { reserveNotificationCooldown } from '../../../../shared/notification-burst-cooldown' +import { reduceHostTurnEnd } from '../../../../shared/agent-hook-turn-end-timing' +import { pickHostTurnEnd } from '../../../../shared/agent-status-types' +import type { AgentHookEventPayload } from '../../../../shared/agent-hook-listener/listener-event' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +const PANE_KEY = makePaneKey('tab-1', LEAF_1) +const SEA = "The sea's waves carry the endless rhythm of tides." + +type TimelineEvent = + | { at: number; title: string; bell?: true } + | { at: number; bell: true } + | { at: number; foreground: string | null } + | { at: number; hook: HookName; message?: string; flagged?: true } + +type HookName = 'UserPromptSubmit' | 'PreToolUse' | 'Stop' | 'Notification' + +// Recorded from Claude Code 2.1.280 over a real PTY (ms from launch): one prompt, then a +// `/btw` side question 45 s later. The side question spins the title and fires no hook. +// The BEL beside each idle title is the burst agents commonly emit at a turn end; the one 600 ms +// after the side question's idle title stands in for Codex /compact's (QA F5b). +const SIDE_QUESTION: TimelineEvent[] = [ + { at: 0, title: '✳ Claude Code' }, + { at: 3_416, hook: 'UserPromptSubmit' }, + { at: 3_686, title: '◐ Claude Code' }, + { at: 4_647, title: '◑ Claude Code' }, + { at: 7_050, title: '◑ The sea' }, + { at: 7_552, title: '◐ The sea' }, + { at: 8_447, hook: 'Stop', message: SEA }, + { at: 8_760, title: '✳ The sea', bell: true }, + { at: 53_328, title: '◑ The sea' }, + { at: 54_299, title: '◐ The sea' }, + { at: 55_146, title: '✳ The sea' }, + { at: 55_746, bell: true }, + // Added: a title spin as the agent quits, with no idle edge before it exits (QA Q2, Codex /quit). + { at: 60_000, title: '◐ The sea' }, + // Added: goose (no hooks, no idle title) replaces Claude in the pane, works, and exits. + { at: 62_000, foreground: 'goose' }, + { at: 80_000, title: '◐ Working' }, + { at: 90_000, foreground: null } +] + +// A Stop hook that blocks twice: Stop #1's banner goes out at 6.5 s, Claude continues text-only and +// re-enters Stop with the same text, then again with the final text 3 s after that banner, both +// inside main's 5 s cooldown. A minute on an idle reminder restates the turn. Then a second turn +// whose re-entered Stop is overtaken by the user's next prompt before its cooldown passes. +const BLOCKED_STOP: TimelineEvent[] = [ + { at: 0, title: '✳ Claude Code' }, + { at: 1_000, hook: 'UserPromptSubmit' }, + { at: 1_200, title: '◐ Claude Code' }, + { at: 5_000, hook: 'Stop', message: 'Draft answer.' }, + { at: 6_000, title: '◑ Claude Code' }, + { at: 8_000, hook: 'Stop', message: 'Draft answer.', flagged: true }, + { at: 9_500, hook: 'Stop', message: 'Final answer.', flagged: true }, + { at: 9_590, title: '✳ Claude Code' }, + { at: 72_000, hook: 'Notification' }, + { at: 80_000, hook: 'UserPromptSubmit' }, + { at: 85_000, hook: 'Stop', message: 'Second turn.' }, + { at: 87_000, hook: 'Stop', message: 'Second turn, revised.', flagged: true }, + { at: 88_000, hook: 'UserPromptSubmit' }, + { at: 93_000, hook: 'Stop', message: 'Third turn.' } +] + +// Prompt hooks the host never received (a POST past its 1.5 s limit). Each such end is ambiguous, +// so the pane keeps main's banners whichever of the title and the hook comes first; a verified +// tool-using continuation still gets its final banner, and a later unflagged repeat stays quiet. +const LOST_OPENINGS: TimelineEvent[] = [ + { at: 0, title: '✳ Claude Code' }, + { at: 1_000, hook: 'UserPromptSubmit' }, + { at: 1_200, title: '◐ Claude Code' }, + { at: 5_000, hook: 'Stop', message: 'First.' }, + { at: 5_300, title: '✳ Claude Code' }, + // Text-only, the idle title before the Stop. + { at: 20_000, title: '◐ Claude Code' }, + { at: 24_000, title: '✳ Claude Code' }, + { at: 24_800, hook: 'Stop', message: 'Second.' }, + { at: 40_000, hook: 'UserPromptSubmit' }, + { at: 40_200, title: '◐ Claude Code' }, + { at: 44_000, hook: 'Stop', message: 'Draft.' }, + { at: 46_000, hook: 'PreToolUse' }, + { at: 52_000, hook: 'Stop', message: 'Final.', flagged: true }, + { at: 52_300, title: '✳ Claude Code' }, + // Text-only, the working title before the missing prompt hook. + { at: 70_000, title: '◐ Claude Code' }, + { at: 74_000, hook: 'Stop', message: 'Third.' }, + { at: 74_300, title: '✳ Claude Code' }, + // Tool-using, the working title first. + { at: 90_000, title: '◐ Claude Code' }, + { at: 90_500, hook: 'PreToolUse' }, + { at: 95_000, hook: 'Stop', message: 'Fourth.' }, + { at: 95_300, title: '✳ Claude Code' }, + { at: 110_000, hook: 'Stop', message: 'Fourth.' } +] + +describe('Claude completion banners through the real pane and hook lanes', () => { + let mockStoreState: StoreState + let transportQueue: MockTransport[] = [] + let transportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + let foreground: string | null = 'claude' + let banners: NotificationDispatchRequest[] = [] + + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportQueue = [] + transportOptions = [] + storeSubscribers = [] + foreground = 'claude' + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportQueue, + getTransportOptions: () => transportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + vi.useFakeTimers() + vi.mocked(window.api.pty.getForegroundProcess).mockImplementation(async () => foreground) + vi.mocked(window.api.pty.hasChildProcesses).mockImplementation(async () => foreground !== null) + // Main's desktop cooldown, keyed as main's delivery service keys it, decides what the user sees. + banners = [] + const recentDesktop = new Map() + vi.mocked(window.api.notifications.dispatch).mockImplementation(async (request) => { + const key = request.attentionKey ?? request.worktreeId ?? 'global' + if (!reserveNotificationCooldown(recentDesktop, key, Date.now())) { + return { delivered: false, reason: 'cooldown' } + } + banners.push(request) + return { delivered: true } + }) + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + async function play(timeline: TimelineEvent[], servedBy?: string): Promise { + const { dispatchTerminalNotification } = await import('./use-notification-dispatch') + const { observeAgentHookCompletionForNotification } = + await import('@/hooks/agent-hook-completion-notifications') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async () => null) + transportQueue.push(transport) + const deps = buildPaneConnectionDeps(() => mockStoreState, { + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' }, + dispatchNotification: (event: Parameters[1]) => + dispatchTerminalNotification('wt-1', event) + }) + await connectMockPanePty(createPane(1), createManager(1), deps) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks(20) + if (servedBy) { + // A pane of a paired server that predates the turn-end stamp (it advertises no capability). + const ptyId = `remote:${servedBy}@@pty-1` + const layout = mockStoreState.terminalLayoutsByTabId?.['tab-1'] + if (!layout) { + throw new Error('Expected the pane layout') + } + vi.mocked(transport.getPtyId).mockReturnValue(ptyId) + mockStoreState.ptyIdsByTabId = { 'tab-1': [ptyId] } + mockStoreState.terminalLayoutsByTabId = { + 'tab-1': { ...layout, ptyIdsByLeafId: { [LEAF_1]: ptyId } } + } + mockStoreState.runtimeStatusByEnvironmentId.set(servedBy, { + checkedAt: Date.now(), + status: { capabilities: [] } + }) + } + + // Stand in for the transport's title tracker: every title, plus its working/idle edge. + const transportHandler = (name: string) => { + const handler: unknown = transportOptions[0]?.[name] + if (typeof handler !== 'function') { + throw new Error(`Expected the pane to register ${name}`) + } + return handler + } + let working = false + let elapsed = 0 + let hostRow: (AgentHookEventPayload & { stateStartedAt?: number }) | undefined + for (const event of timeline) { + await vi.advanceTimersByTimeAsync(event.at - elapsed) + elapsed = event.at + if ('foreground' in event) { + foreground = event.foreground + continue + } + if (!('title' in event) && 'bell' in event) { + transportHandler('onBell')() + continue + } + if ('title' in event) { + transportHandler('onTitleChange')(event.title, event.title) + const nowWorking = !event.title.startsWith('✳') + if (nowWorking !== working) { + transportHandler(nowWorking ? 'onAgentBecameWorking' : 'onAgentBecameIdle')(event.title) + } + working = nowWorking + if (event.bell) { + transportHandler('onBell')() + } + continue + } + // The host's own derivation: its state clock, end stamp and disposition. + const hookEvent: AgentHookEventPayload = { + paneKey: PANE_KEY, + connectionId: null, + source: 'claude', + hookEventName: event.hook, + ...(event.flagged ? { stopHookActive: true } : {}), + payload: { + state: event.hook === 'Stop' || event.hook === 'Notification' ? 'done' : 'working', + prompt: 'Reply with one short sentence.', + agentType: 'claude', + ...(event.message ? { lastAssistantMessage: event.message } : {}) + } + } + const stateStartedAt = + hostRow?.payload.state === hookEvent.payload.state ? hostRow.stateStartedAt : Date.now() + hostRow = { ...hookEvent, ...reduceHostTurnEnd(hostRow, hookEvent, Date.now(), true) } + hostRow.stateStartedAt = stateStartedAt + const payload: AgentCompletionStatusSnapshot = { + ...hookEvent.payload, + stateStartedAt, + ...pickHostTurnEnd(hostRow) + } + mockStoreState.agentStatusByPaneKey[PANE_KEY] = { + ...payload, + paneKey: PANE_KEY, + updatedAt: Date.now(), + stateHistory: [] + } + observeAgentHookCompletionForNotification({ paneKey: PANE_KEY, worktreeId: 'wt-1', payload }) + } + await vi.advanceTimersByTimeAsync(5_000) + } + + const rich = (message: string) => + expect.objectContaining({ source: 'agent-task-complete', agentLastAssistantMessage: message }) + + it('announces the turn once; a /btw, its late BEL and quitting stay quiet, a hookless successor does not', async () => { + await play(SIDE_QUESTION) + // Claude is replaced in the pane by goose (no hooks), which later exits. + foreground = 'goose' + await vi.advanceTimersByTimeAsync(15_000) + foreground = null + await vi.advanceTimersByTimeAsync(15_000) + + expect(banners).toEqual([ + expect.objectContaining({ agentType: 'claude', agentLastAssistantMessage: SEA }), + expect.objectContaining({ source: 'agent-task-complete', terminalTitle: 'goose' }) + ]) + }) + + it("keeps main's banners for a /btw on a pane whose paired server predates the stamp", async () => { + await play(SIDE_QUESTION, 'old-server') + + // The hook banner wins the burst; the /btw gets main's generic title banner, which absorbs its BEL. + expect(banners).toEqual([ + rich(SEA), + expect.objectContaining({ source: 'agent-task-complete', terminalTitle: '✳ The sea' }) + ]) + expect(banners[1]).not.toHaveProperty('agentLastAssistantMessage') + }) + + it("keeps the latest end of a blocking Stop hook turn past main's cooldown, once", async () => { + await play(BLOCKED_STOP) + + expect(banners).toEqual([ + rich('Draft answer.'), + rich('Final answer.'), + rich('Second turn.'), + rich('Third turn.') + ]) + }) + + const LOST_OPENING_BANNERS = ['First.', 'Second.', 'Draft.', 'Final.', 'Third.', 'Fourth.'] + + it('keeps every banner when the host missed a prompt hook, whichever title edge comes first', async () => { + await play(LOST_OPENINGS) + expect(banners).toEqual(LOST_OPENING_BANNERS.map(rich)) + }) + + it('matches the banners of a paired server that predates the stamp', async () => { + await play(LOST_OPENINGS, 'old-server') + expect(banners).toEqual(LOST_OPENING_BANNERS.map(rich)) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts index ea8a64f5b07..890d29ebf87 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts @@ -34,6 +34,7 @@ type MockStoreState = { setCacheTimerStartedAt: ReturnType observeTerminalGitHubPullRequestLink: ReturnType agentStatusByPaneKey: Record + paneForegroundAgentByPaneKey: Record } const dispatchTerminalNotification = vi.fn() @@ -81,7 +82,8 @@ function createMockStoreState(): MockStoreState { markTerminalBell: vi.fn(), setCacheTimerStartedAt: vi.fn(), observeTerminalGitHubPullRequestLink: vi.fn(), - agentStatusByPaneKey: {} + agentStatusByPaneKey: {}, + paneForegroundAgentByPaneKey: {} } } diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts index e4cddfdaf9d..f8160df3a45 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts @@ -11,6 +11,7 @@ import { useAppStore } from '@/store' import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector' import { AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS, + AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS, isAgentTaskCompleteOsNotificationEnabledFromState, isAgentTaskCompleteTrackingEnabledFromState } from './agent-task-complete-policy' @@ -28,6 +29,8 @@ import { registerTerminalSideEffectFactConsumer } from './terminal-side-effect-facts-handler' import { dispatchTerminalNotification } from './use-notification-dispatch' +import { hasPendingHookLaneCompletion } from '@/hooks/agent-hook-completion-notifications' +import { hookLaneTitleTurnEndEvidence } from '@/hooks/agent-hook-turn-end-ownership' import { acquireHiddenRendererPtyDeliveryClaim } from './pty-renderer-delivery-claims' import { isRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' @@ -84,6 +87,7 @@ export function startParkedTerminalByteWatcher( let wroteRuntimeTitleSlot = false let bellNotificationTimer: ReturnType | null = null let agentTaskCompleteTimer: ReturnType | null = null + let ownedIdleTimer: ReturnType | null = null const clearBellNotificationTimer = (): void => { if (bellNotificationTimer !== null) { @@ -97,6 +101,10 @@ export function startParkedTerminalByteWatcher( clearTimeout(agentTaskCompleteTimer) agentTaskCompleteTimer = null } + if (ownedIdleTimer !== null) { + clearTimeout(ownedIdleTimer) + ownedIdleTimer = null + } } // Why: a BEL OS notification only yields when the pending completion would itself produce an OS notification (live-path parity). @@ -118,6 +126,13 @@ export function startParkedTerminalByteWatcher( return } pendingBellNotification = false + // Why: the hook lane's pending Done announces this burst's turn end, superseding the BEL as a pane completion does. + if ( + hasPendingHookLaneCompletion(paneKey, ptyId) && + isAgentTaskCompleteOsNotificationEnabled(useAppStore.getState()) + ) { + return + } dispatchTerminalNotification(worktreeId, { source: 'terminal-bell', paneKey, @@ -171,13 +186,30 @@ export function startParkedTerminalByteWatcher( // Why: completion supersedes a concurrent BEL so each burst yields exactly one OS notification (live-path parity). pendingBellNotification = false clearBellNotificationTimer() - dispatchTerminalNotification(worktreeId, { - source: 'agent-task-complete', - terminalTitle: title, - paneKey, - ptyId, - workspaceOwner - }) + const evidence = (): 'identified' | 'ambiguous' | null => { + const foreground = useAppStore.getState().paneForegroundAgentByPaneKey[paneKey]?.agent + return hookLaneTitleTurnEndEvidence(paneKey, ptyId, title, foreground ?? null) + } + const dispatchIdleCompletion = (): void => { + dispatchTerminalNotification(worktreeId, { + source: 'agent-task-complete', + terminalTitle: title, + paneKey, + ptyId, + workspaceOwner + }) + } + if (evidence() !== 'identified') { + dispatchIdleCompletion() + return + } + // Why: as on a mounted pane, an end the host then calls ambiguous keeps main's title completion. + ownedIdleTimer = setTimeout(() => { + ownedIdleTimer = null + if (!disposed && evidence() === 'ambiguous') { + dispatchIdleCompletion() + } + }, AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS - PARKED_NOTIFICATION_GRACE_MS) }, PARKED_NOTIFICATION_GRACE_MS) }, onAgentBecameWorking: (): void => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/agent-task-complete-notify.ts b/src/renderer/src/components/terminal-pane/pty-connection/agent-task-complete-notify.ts index 057ea8af6d5..a5c942ecc24 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/agent-task-complete-notify.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/agent-task-complete-notify.ts @@ -20,6 +20,22 @@ import type { ConnectPanePtySession } from './connect-pane-pty-session' import { installAgentIdleWorkingHandlers } from './agent-idle-working-handlers' +/** A hook-owned idle edge still ends its output burst, so like a claimed title completion it + * absorbs that burst's BEL: one held now, or one that lands within the completion's max wait. */ +export function absorbBurstBellForHookOwnedIdle(session: ConnectPanePtySession): void { + if (session.hasPendingAgentTaskCompleteNotification()) { + return + } + session.pendingTerminalBellNotification = false + session.clearTerminalBellNotificationTimer() + session.hookOwnedIdleAbsorbsBell = true + // Why: main's claimed completion stayed pending on a `done` row for its whole max wait. + session.agentTaskCompleteNotificationMaxTimer = setTimeout(() => { + session.agentTaskCompleteNotificationMaxTimer = null + session.hookOwnedIdleAbsorbsBell = false + }, AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) +} + export function installAgentTaskCompleteNotify(session: ConnectPanePtySession): void { session.scheduleAgentTaskCompleteNotification = ( title: string, diff --git a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts index 067515eaba5..3729163680e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts @@ -84,6 +84,7 @@ export function connectPanePty( session.startupInjectTimer = null session.agentTaskCompleteNotificationGraceTimer = null session.agentTaskCompleteNotificationMaxTimer = null + session.hookOwnedIdleAbsorbsBell = false session.agentTaskCompleteStatusUnsubscribe = null session.agentTaskCompleteSettingsUnsubscribe = null session.agentTaskCompleteNotificationGeneration = 0 diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-pty-visibility-bind.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-pty-visibility-bind.ts index 9e722f81e37..abed6b4727b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-pty-visibility-bind.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-pty-visibility-bind.ts @@ -8,6 +8,7 @@ import { notifyCodexPaneBoundForStaleSweep } from '@/lib/codex-stale-pane-sweep' import { createTerminalGitHubPRLinkDetector } from '../../../../../shared/terminal-github-pr-link-detector' import { setRendererPtyVisibilityClaim } from '../pty-renderer-delivery-claims' import { AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS } from '../agent-task-complete-policy' +import { hasPendingHookLaneCompletion } from '@/hooks/agent-hook-completion-notifications' import { isAgentTaskCompleteNotificationEnabled, @@ -212,6 +213,10 @@ export function installPanePtyVisibilityBind(session: ConnectPanePtySession): vo // Why: agent CLIs often emit BEL in the same completion burst as their // working->idle title change. Delay only the OS notification so the richer // agent-complete notification can win the main-process worktree cooldown. + // Why: this BEL ends a hook-owned turn the hook lane announces; held, it would ring on the next working edge. + if (session.hookOwnedIdleAbsorbsBell === true) { + return + } session.pendingTerminalBellNotification = true if (!session.hasPendingAgentTaskCompleteNotification()) { session.scheduleTerminalBellNotification() @@ -239,6 +244,13 @@ export function installPanePtyVisibilityBind(session: ConnectPanePtySession): vo return } session.pendingTerminalBellNotification = false + // Why: the hook lane's pending Done announces this burst's turn end, superseding the BEL as a pane completion does. + if ( + isAgentTaskCompleteNotificationEnabled() && + hasPendingHookLaneCompletion(session.cacheKey, session.transport.getPtyId()) + ) { + return + } session.deps.dispatchNotification({ source: 'terminal-bell', paneKey: session.cacheKey, @@ -264,6 +276,7 @@ export function installPanePtyVisibilityBind(session: ConnectPanePtySession): vo clearTimeout(session.agentTaskCompleteNotificationMaxTimer) session.agentTaskCompleteNotificationMaxTimer = null } + session.hookOwnedIdleAbsorbsBell = false if (session.agentTaskCompleteStatusUnsubscribe !== null) { session.agentTaskCompleteStatusUnsubscribe() session.agentTaskCompleteStatusUnsubscribe = null diff --git a/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts b/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts index bbed206576f..8f60a88fc77 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts @@ -8,6 +8,11 @@ import { isFreshNonDoneAgentStatus } from '../../../../../shared/agent-status-ty import { isCtrlCKeyEvent, isPlainEscapeKeyEvent } from '../agent-interrupt-inference' import { createAgentCompletionCoordinator } from '../agent-completion-coordinator' import { dispatchAgentHookTerminalLifecycle } from '../agent-hook-terminal-lifecycle' +import { + hookLaneOwnsAgentTurnEnds, + hookLaneTitleTurnEndEvidence +} from '@/hooks/agent-hook-turn-end-ownership' +import { absorbBurstBellForHookOwnedIdle } from './agent-task-complete-notify' import { resolveCompatibleAgentTypeForOwner } from '../../../../../shared/agent-title-owner' import { registerTerminalSideEffectFactConsumer } from '../terminal-side-effect-facts-handler' @@ -176,7 +181,19 @@ export function installTerminalKeydownFit(session: ConnectPanePtySession): void inspectProcess: inspectRuntimeTerminalProcess, dispatchHookLifecycle: (payload) => dispatchAgentHookTerminalLifecycle(session.cacheKey, payload), - shouldSuppressProcessReplacementCompletion: (_exited, replacement) => { + titleTurnEndEvidence: (title, foregroundAgent) => + hookLaneTitleTurnEndEvidence( + session.cacheKey, + session.transport.getPtyId(), + title, + foregroundAgent + ), + onHookOwnedTitleIdle: () => absorbBurstBellForHookOwnedIdle(session), + shouldSuppressProcessReplacementCompletion: (exited, replacement) => { + // Why: the hook lane owns this pane's turn ends and its last turn ended, so quitting ends none. + if (hookLaneOwnsAgentTurnEnds(session.cacheKey, session.transport.getPtyId(), exited.agent)) { + return true + } const currentStatus = useAppStore.getState().agentStatusByPaneKey[session.cacheKey] const currentAgentForReplacement = resolveCompatibleAgentTypeForOwner( currentStatus?.agentType, @@ -187,6 +204,9 @@ export function installTerminalKeydownFit(session: ConnectPanePtySession): void ) }, shouldSuppressConfirmedProcessExitCompletion: (exited) => { + if (hookLaneOwnsAgentTurnEnds(session.cacheKey, session.transport.getPtyId(), exited.agent)) { + return true + } const currentStatus = useAppStore.getState().agentStatusByPaneKey[session.cacheKey] const currentAgentForExited = resolveCompatibleAgentTypeForOwner( currentStatus?.agentType, diff --git a/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts b/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts index e4d62dadaa7..962bba9c487 100644 --- a/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts +++ b/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts @@ -21,6 +21,7 @@ import type { AgentCompletionDispatchMeta, AgentCompletionStatusSnapshot } from './agent-completion-coordinator-types' +import type { NotificationDispatchResult } from '../../../../shared/notification-settings-types' import { getNotificationWorkspaceLabels } from './terminal-notification-state' import { createTerminalAttentionSurface } from './terminal-attention-surface' import { @@ -61,6 +62,8 @@ export type TerminalNotificationEvent = TerminalNotificationBinding & { terminalTitle?: string agentStatusSnapshot?: AgentCompletionStatusSnapshot agentCompletionSource?: AgentCompletionDispatchMeta['source'] + /** Main's answer to the delivery request this event sends (null: no answer). */ + onDispatchResult?: (result: NotificationDispatchResult | null) => void } /** @@ -73,7 +76,7 @@ export type TerminalNotificationEvent = TerminalNotificationBinding & { export function dispatchTerminalNotification( worktreeId: string, event: TerminalNotificationEvent -): void { +): boolean { const state = useAppStore.getState() // Why: the completion title is the live identity. If it explicitly names an // agent, any snapshot from another agent is stale pane-reuse residue and must @@ -105,7 +108,7 @@ export function dispatchTerminalNotification( ) { // Why: a title-only idle signal can race behind active hook state; a // confirmed process exit is independent authority that the turn ended. - return + return false } // Why: a process can die before its hook emits done; do not label the // resulting completion notification with that stale active state or prompt. @@ -120,7 +123,7 @@ export function dispatchTerminalNotification( event.source === 'agent-task-complete' && isSupersededAgentCompletionSnapshot(storedAgentStatus, eventAgentStatusSnapshot) ) { - return + return false } const agentNotificationStateStartedAt = eventAgentStatusSnapshot?.stateStartedAt ?? freshStoredAgentStatus?.stateStartedAt @@ -137,7 +140,7 @@ export function dispatchTerminalNotification( createTerminalAttentionSurface(state) ) if (!attentionDecision.admitted) { - return + return false } // Desktop settings are applied in main after independent mobile delivery. @@ -168,7 +171,9 @@ export function dispatchTerminalNotification( }) : null + let requested = false const requestDelivery = (request: AgentAttentionDeliveryRequest): void => { + requested = true const owner = resolveTerminalNotificationOwner(state, worktreeId, event) const executionHost = notificationExecutionHostForOwner(owner) deliverAgentAttentionNotification( @@ -184,7 +189,8 @@ export function dispatchTerminalNotification( isActiveWorktree: request.workspaceIsActive, ...agentSnapshot }, - sound + sound, + event.onDispatchResult ) } @@ -192,13 +198,16 @@ export function dispatchTerminalNotification( unread: state.markAgentAttentionUnread, requestDelivery }) + return requested } export function useNotificationDispatch( worktreeId: string ): (event: TerminalNotificationEvent) => void { return useCallback( - (event: TerminalNotificationEvent) => dispatchTerminalNotification(worktreeId, event), + (event: TerminalNotificationEvent) => { + dispatchTerminalNotification(worktreeId, event) + }, [worktreeId] ) } diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.ts index e90d2255383..9c7fde5c692 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.ts @@ -8,12 +8,18 @@ import type { import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection' import { dispatchTerminalNotification } from '@/components/terminal-pane/use-notification-dispatch' import { collectLeafIdsInOrder } from '@/components/terminal-pane/layout-serialization' +import { getPtyIdForPaneKey } from './pane-key-pty-id' import { dispatchAgentHookTerminalLifecycle } from '@/components/terminal-pane/agent-hook-terminal-lifecycle' import { isAgentHookCompletionTrackingEnabled, shouldSyncAgentHookCompletionForStoreUpdate, type AgentHookCompletionStoreSnapshot } from './agent-hook-completion-store-sync' +import { hookTurnEndEvidence } from './agent-hook-turn-end-capability' +import { + discardHookLaneCompletionRequests, + submitHookLaneCompletion +} from './agent-hook-completion-revision-hold' type CoordinatorEntry = { worktreeId: string @@ -40,6 +46,7 @@ let lastPrunedLivenessSnapshot: PaneCoordinatorLivenessSnapshot | null = null function disposeCoordinatorForPaneKey(paneKey: string): void { coordinatorsByPaneKey.get(paneKey)?.coordinator.dispose() coordinatorsByPaneKey.delete(paneKey) + discardHookLaneCompletionRequests(paneKey) paneKeysRequiringFreshWorking.delete(paneKey) } @@ -140,44 +147,6 @@ export function syncAgentHookCompletionNotificationsForStoreUpdate( return true } -function getPtyIdForPaneKey(paneKey: string): string | null { - const parsed = parsePaneKey(paneKey) - if (!parsed) { - return null - } - const state = useAppStore.getState() - const tabPtyIds = state.ptyIdsByTabId?.[parsed.tabId] - if (!tabPtyIds || tabPtyIds.length === 0) { - return null - } - // Why: split-pane leaves share one tab-level pty list, so a tab-level lookup - // would return a sibling's pty for an already-closed leaf and let a late - // 'done' hook event fire a spurious notification. Resolve liveness through - // the leaf-keyed binding maintained by syncPanePtyLayoutBinding, which - // deletes the entry when the leaf closes. - const layout = state.terminalLayoutsByTabId?.[parsed.tabId] - const ptyIdsByLeafId = layout?.ptyIdsByLeafId - if (ptyIdsByLeafId) { - const leafPtyId = ptyIdsByLeafId[parsed.leafId] - if (leafPtyId && tabPtyIds.includes(leafPtyId)) { - return leafPtyId - } - if (!layout?.root) { - // Why: inactive worktree switches can temporarily preserve only tab-level - // PTY liveness; do not drop hook completions just because layout metadata - // is at the empty snapshot. - return tabPtyIds[0] ?? null - } - // Why: switching worktrees can unmount the terminal pane and clear the - // leaf binding before the hook completion arrives, while the tab PTY is - // still live. Keep closed leaves suppressed by requiring the leaf in layout. - return collectLeafIdsInOrder(layout.root).includes(parsed.leafId) - ? (tabPtyIds[0] ?? null) - : null - } - return tabPtyIds[0] ?? null -} - function paneHasLivePty(paneKey: string): boolean { return getPtyIdForPaneKey(paneKey) !== null } @@ -231,29 +200,48 @@ function paneCanReceiveHookCompletion(paneKey: string, tabIndex?: TabIndex): boo return paneKeyHasUnsuppressedPtyHint(state, paneKey, tabIndex) || paneHasLivePty(paneKey) } +function hookLaneDispatchEnabled(paneKey: string): boolean { + return isAgentTaskCompleteTrackingEnabled() && !paneKeysRequiringFreshWorking.has(paneKey) +} + +function hookLaneOwnsTurnEnd(paneKey: string, snapshot: AgentCompletionStatusSnapshot): boolean { + return hookTurnEndEvidence(getPtyIdForPaneKey(paneKey), snapshot) === 'identified' +} + function createCoordinator(paneKey: string, worktreeId: string): AgentCompletionCoordinator { return createAgentCompletionCoordinator({ paneKey, statusLane: 'hook', getPtyId: () => getPtyIdForPaneKey(paneKey), + usesHookTurnEnds: (snapshot) => hookLaneOwnsTurnEnd(paneKey, snapshot), inspectProcess: async (): Promise => ({ foregroundProcess: null, hasChildProcesses: false }), dispatchHookLifecycle: (payload) => dispatchAgentHookTerminalLifecycle(paneKey, payload), dispatchCompletion: (title, meta) => { - if (!isAgentTaskCompleteTrackingEnabled() || paneKeysRequiringFreshWorking.has(paneKey)) { + if (!hookLaneDispatchEnabled(paneKey)) { return } - dispatchTerminalNotification(worktreeId, { - source: 'agent-task-complete', - terminalTitle: title, - paneKey, - ...(meta?.agentStatus ? { agentStatusSnapshot: meta.agentStatus } : {}) + const snapshot = meta?.agentStatus + submitHookLaneCompletion(paneKey, snapshot, { + keepsAuthority: () => + hookLaneDispatchEnabled(paneKey) && + coordinatorsByPaneKey.has(paneKey) && + snapshot !== undefined && + hookLaneOwnsTurnEnd(paneKey, snapshot), + submit: (onDispatchResult) => + dispatchTerminalNotification(worktreeId, { + source: 'agent-task-complete', + terminalTitle: title, + paneKey, + ...(snapshot ? { agentStatusSnapshot: snapshot } : {}), + onDispatchResult + }) }) }, dispatchAttention: (title, meta) => { - if (!isAgentTaskCompleteTrackingEnabled() || paneKeysRequiringFreshWorking.has(paneKey)) { + if (!hookLaneDispatchEnabled(paneKey)) { return } // Why: native notification settings still label this channel as "agent @@ -298,6 +286,7 @@ export function observeAgentHookCompletionForNotification({ let entry = coordinatorsByPaneKey.get(paneKey) if (!entry || entry.worktreeId !== worktreeId) { entry?.coordinator.dispose() + discardHookLaneCompletionRequests(paneKey) entry = { worktreeId, coordinator: createCoordinator(paneKey, worktreeId) @@ -312,16 +301,35 @@ export function observeAgentHookCompletionForNotification({ if (payload.state === 'working' && payload.turnCompletedAt === undefined && trackingEnabled) { paneKeysRequiringFreshWorking.delete(paneKey) } + // Why: on a legacy pane the host's end facts must not change main's done identity or replay guard. + const { turnEndedAt: _turnEndedAt, turnEndDisposition: _disposition, ...legacyPayload } = payload + const observed = + hookTurnEndEvidence(getPtyIdForPaneKey(paneKey), payload) === 'legacy' ? legacyPayload : payload if (seedOnly === true) { - entry.coordinator.seedHookStatus(payload) + entry.coordinator.seedHookStatus(observed) } else { - entry.coordinator.observeHookStatus(payload) + entry.coordinator.observeHookStatus(observed) } } +// Why: asked of the lane itself so title lanes defer only where it would announce: live, agent evidence, not re-arming. +export function hookLaneCanAnnouncePaneTurnEnd(paneKey: string): boolean { + const coordinator = coordinatorsByPaneKey.get(paneKey)?.coordinator + return coordinator?.canAnnounceCompletion() === true && hookLaneDispatchEnabled(paneKey) +} + +export function hasPendingHookLaneCompletion(paneKey: string, ptyId: string | null): boolean { + return ( + coordinatorsByPaneKey.get(paneKey)?.coordinator.hasPendingHookDoneCompletion() === true && + hookTurnEndEvidence(ptyId, useAppStore.getState().agentStatusByPaneKey[paneKey]) === + 'identified' + ) +} + export function resetAgentHookCompletionNotificationCoordinators(): void { - for (const entry of coordinatorsByPaneKey.values()) { + for (const [paneKey, entry] of coordinatorsByPaneKey) { entry.coordinator.dispose() + discardHookLaneCompletionRequests(paneKey) } coordinatorsByPaneKey.clear() paneKeysRequiringFreshWorking.clear() diff --git a/src/renderer/src/hooks/agent-hook-completion-revision-hold.ts b/src/renderer/src/hooks/agent-hook-completion-revision-hold.ts new file mode 100644 index 00000000000..562486b8b84 --- /dev/null +++ b/src/renderer/src/hooks/agent-hook-completion-revision-hold.ts @@ -0,0 +1,163 @@ +import { useAppStore } from '@/store' +import type { AgentCompletionStatusSnapshot } from '@/components/terminal-pane/agent-completion-coordinator-types' +import { NOTIFICATION_COOLDOWN_MS } from '../../../shared/notification-burst-cooldown' +import type { NotificationDispatchResult } from '../../../shared/notification-settings-types' + +/** Sends one request and reports main's answer (null: none); returns false when nothing was sent. */ +export type HookLaneCompletionSubmit = ( + onResult: (result: NotificationDispatchResult | null) => void +) => boolean + +type HeldRevision = { + submit: HookLaneCompletionSubmit + isCurrent: () => boolean + expiresAt: number +} + +type PaneRequests = { + /** The done segment (`stateStartedAt`) of the pane's latest request. */ + segment: number | undefined + requestSeq: number + /** When main's cooldown from the latest request has surely passed; null while it is unanswered. */ + cooldownEndsAt: number | null + held: HeldRevision | null + timer: ReturnType | null +} + +// Why: these answers mean a later request would be refused too, so a held revision is dropped. +const TERMINAL_REFUSALS: ReadonlySet = new Set([ + 'disabled', + 'source-disabled', + 'host-muted', + 'not-supported', + 'blocked-by-system', + 'invalid-request' +]) + +const requestsByPaneKey = new Map() + +function clearTimer(requests: PaneRequests): void { + if (requests.timer !== null) { + clearTimeout(requests.timer) + requests.timer = null + } +} + +function armHeldTimer(requests: PaneRequests, submitAfterCooldown: () => void): void { + const held = requests.held + if (!held) { + return + } + clearTimer(requests) + const cooldownEndsAt = requests.cooldownEndsAt + if (cooldownEndsAt !== null && cooldownEndsAt > held.expiresAt) { + requests.held = null + return + } + // Why: a fixed expiry, never extended by later revisions, also drops one whose answer never came. + const dueAt = cooldownEndsAt ?? held.expiresAt + requests.timer = setTimeout( + () => { + requests.timer = null + if (cooldownEndsAt === null) { + requests.held = null + return + } + submitAfterCooldown() + }, + Math.max(0, dueAt - Date.now()) + ) +} + +function submitNow( + paneKey: string, + requests: PaneRequests, + segment: number | undefined, + submit: HookLaneCompletionSubmit +): void { + const seq = ++requests.requestSeq + requests.segment = segment + requests.cooldownEndsAt = null + const sent = submit((result) => { + if (requestsByPaneKey.get(paneKey) !== requests || requests.requestSeq !== seq) { + return + } + // Why: main reserved its cooldown before answering, so counting from the answer is conservative. + requests.cooldownEndsAt = Date.now() + NOTIFICATION_COOLDOWN_MS + if (result && !result.delivered && TERMINAL_REFUSALS.has(result.reason)) { + requests.held = null + clearTimer(requests) + return + } + armHeldTimer(requests, () => submitHeld(paneKey, requests)) + }) + if (!sent && requests.requestSeq === seq) { + requests.cooldownEndsAt = 0 + } +} + +function submitHeld(paneKey: string, requests: PaneRequests): void { + const held = requests.held + requests.held = null + // One attempt: owner, state and capability are rechecked here, focus and settings by main. + if (held?.isCurrent()) { + submitNow(paneKey, requests, requests.segment, held.submit) + } +} + +/** + * The hook lane's completion requests for one pane. A later turn end in the same done segment + * (a Stop re-entered after a blocking Stop hook) that arrives while main's cooldown from the + * preceding request still runs would be dropped there; the latest one is held and sent once, + * after that cooldown, through main's unchanged gates. Everything else is sent at once. + */ +export function submitHookLaneCompletion( + paneKey: string, + snapshot: AgentCompletionStatusSnapshot | undefined, + lane: { keepsAuthority: () => boolean; submit: HookLaneCompletionSubmit } +): void { + const done = snapshot?.state === 'done' ? snapshot : undefined + const segment = done?.stateStartedAt + // Why: sent only while the pane still shows this end and the lane keeps hook authority. + const isCurrent = (): boolean => { + const row = useAppStore.getState().agentStatusByPaneKey[paneKey] + return ( + row?.state === 'done' && + row.stateStartedAt === segment && + row.turnEndedAt === done?.turnEndedAt && + lane.keepsAuthority() + ) + } + let requests = requestsByPaneKey.get(paneKey) + if (!requests) { + requests = { segment: undefined, requestSeq: 0, cooldownEndsAt: 0, held: null, timer: null } + requestsByPaneKey.set(paneKey, requests) + } + const coolingDown = requests.cooldownEndsAt === null || Date.now() < requests.cooldownEndsAt + if ( + done?.turnEndedAt !== undefined && + segment !== undefined && + segment === requests.segment && + coolingDown + ) { + requests.held = { + submit: lane.submit, + isCurrent, + expiresAt: requests.held?.expiresAt ?? Date.now() + 2 * NOTIFICATION_COOLDOWN_MS + } + const paneRequests = requests + armHeldTimer(requests, () => submitHeld(paneKey, paneRequests)) + return + } + clearTimer(requests) + requests.held = null + submitNow(paneKey, requests, segment, lane.submit) +} + +export function discardHookLaneCompletionRequests(paneKey: string): void { + const requests = requestsByPaneKey.get(paneKey) + if (requests) { + clearTimer(requests) + requestsByPaneKey.delete(paneKey) + } +} diff --git a/src/renderer/src/hooks/agent-hook-turn-end-capability.ts b/src/renderer/src/hooks/agent-hook-turn-end-capability.ts new file mode 100644 index 00000000000..a24bebaeb1d --- /dev/null +++ b/src/renderer/src/hooks/agent-hook-turn-end-capability.ts @@ -0,0 +1,44 @@ +import { useAppStore } from '@/store' +import { + isRemoteRuntimePtyId, + parseRemoteRuntimePtyId +} from '../../../shared/remote-runtime-pty-id' +import { lastVerifiedRuntimeStatus } from '../../../shared/runtime-host-status' +import { AGENT_STATUS_TURN_END_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import type { HostTurnEndFacts } from '../../../shared/agent-status-types' + +// Why: a paired server (incl. SSH) that predates the turn-end stamp can't tell a later turn end +// from a restatement. Local and WSL-relay PTYs always run this build; an unbound PTY is unknown. +function ptyHostStampsTurnEnds(ptyId: string | null | undefined): boolean { + if (!ptyId) { + return false + } + if (!isRemoteRuntimePtyId(ptyId)) { + return true + } + const environmentId = parseRemoteRuntimePtyId(ptyId)?.environmentId + const entry = environmentId + ? useAppStore.getState().runtimeStatusByEnvironmentId.get(environmentId) + : undefined + return ( + lastVerifiedRuntimeStatus(entry)?.capabilities?.includes( + AGENT_STATUS_TURN_END_RUNTIME_CAPABILITY + ) === true + ) +} + +export type HookTurnEndEvidence = 'identified' | 'ambiguous' | 'legacy' + +/** + * The one decision for every pane behavior hook-owned turn ends changed, the same for every agent: + * the pane's bound PTY runs on a host that stamps turn ends, and how that host identified the + * current end. `identified`: the hook lane owns it. `ambiguous`: main's title role stays. `legacy` + * (old host, unbound PTY, no disposition): main's behavior exactly. + */ +export function hookTurnEndEvidence( + ptyId: string | null | undefined, + row: Pick | undefined +): HookTurnEndEvidence { + const disposition = row?.turnEndDisposition + return disposition && ptyHostStampsTurnEnds(ptyId) ? disposition : 'legacy' +} diff --git a/src/renderer/src/hooks/agent-hook-turn-end-ownership.ts b/src/renderer/src/hooks/agent-hook-turn-end-ownership.ts new file mode 100644 index 00000000000..e49bf59202d --- /dev/null +++ b/src/renderer/src/hooks/agent-hook-turn-end-ownership.ts @@ -0,0 +1,57 @@ +import { useAppStore } from '@/store' +import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' +import { resolveCompatibleAgentTypeForOwner } from '../../../shared/agent-title-owner' +import { isAgentStatusHooksEnabledForAgent } from '../../../shared/agent-status-hooks-setting' +import { isTuiAgent } from '../../../shared/tui-agent-config' +import type { TerminalAgent } from '../../../shared/terminal-agent' +import { hookLaneCanAnnouncePaneTurnEnd } from './agent-hook-completion-notifications' +import { hookTurnEndEvidence } from './agent-hook-turn-end-capability' + +/** + * The evidence of the hook-lane turn end a title or process naming `agent` (null: none) would + * otherwise announce: `identified` (the hook lane announces it), `ambiguous` (main's title role + * stays), or null (not a hook-lane turn end). A title spin (Claude's /btw, Codex's /compact) or + * quitting the agent after an identified end ends no hook turn, so it must not announce one. + */ +export function hookLaneTurnEndEvidence( + paneKey: string, + ptyId: string | null | undefined, + agent: TerminalAgent | null +): 'identified' | 'ambiguous' | null { + const { agentStatusByPaneKey, settings } = useAppStore.getState() + const row = agentStatusByPaneKey[paneKey] + const owner = row?.agentType + const evidence = hookTurnEndEvidence(ptyId, row) + // Why: the title lane stays the fallback for an unfinished turn, and once the agent's hooks are + // switched off (they are uninstalled, so the hook lane never hears another turn end). + if ( + row?.state !== 'done' || + evidence === 'legacy' || + !isTuiAgent(owner) || + !isAgentStatusHooksEnabledForAgent(settings, owner) || + !hookLaneCanAnnouncePaneTurnEnd(paneKey) + ) { + return null + } + return !agent || resolveCompatibleAgentTypeForOwner(owner, agent) === agent ? evidence : null +} + +export function hookLaneOwnsAgentTurnEnds( + paneKey: string, + ptyId: string | null | undefined, + agent: TerminalAgent | null +): boolean { + return hookLaneTurnEndEvidence(paneKey, ptyId, agent) === 'identified' +} + +/** A title edge's turn belongs to the title's agent, else the pane's foreground agent (goose + * after Claude), the same on idle and working edges. */ +export function hookLaneTitleTurnEndEvidence( + paneKey: string, + ptyId: string | null | undefined, + title: string | null, + foregroundAgent: TerminalAgent | null +): 'identified' | 'ambiguous' | null { + const agent = (title ? resolveCommittedTitleAgentType(title) : null) ?? foregroundAgent + return hookLaneTurnEndEvidence(paneKey, ptyId, agent) +} diff --git a/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts b/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts index b1d3fc5a110..1a572f4fd15 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts @@ -1,6 +1,6 @@ import { sameAgentStatusTerminalOwner } from '../../../../shared/agent-status-terminal-owner' import { isWslHookRelayConnectionId } from '../../../../shared/wsl-hook-relay-contract' -import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types' +import { pickHostTurnEnd, type AgentStatusIpcPayload } from '../../../../shared/agent-status-types' import { resolveAgentStatusIdentity, shouldSuppressInheritedTerminalStatus @@ -240,7 +240,8 @@ export function createAgentStatusEventApplicator(args: { ? { evidenceObservedAt: data.evidenceObservedAt } : {}), stateStartedAt: data.stateStartedAt, - ...(Number.isFinite(data.turnStartedAt) ? { turnStartedAt: data.turnStartedAt } : {}) + ...(Number.isFinite(data.turnStartedAt) ? { turnStartedAt: data.turnStartedAt } : {}), + ...pickHostTurnEnd(data) }, routing: { tabId: ownerTabId, @@ -255,10 +256,11 @@ export function createAgentStatusEventApplicator(args: { options?.replay === true || isSeenHostReplay(data, resolvedPayload.state, existingStatus) const applyPostCommitNotification = (): void => { if (statusWorktreeId && (!replayed || resolvedPayload.state === 'working')) { - const notificationPayload = - typeof data.stateStartedAt === 'number' - ? { ...resolvedPayload, stateStartedAt: data.stateStartedAt } - : resolvedPayload + const notificationPayload = { + ...resolvedPayload, + stateStartedAt: data.stateStartedAt, + ...pickHostTurnEnd(data) + } observeAgentHookCompletionForNotification({ paneKey, worktreeId: statusWorktreeId, diff --git a/src/renderer/src/hooks/ipc-events/agent-status-routing.ts b/src/renderer/src/hooks/ipc-events/agent-status-routing.ts index e6eeba4a4f1..ba7a429e1da 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-routing.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-routing.ts @@ -31,9 +31,14 @@ export function isAgentStatusForRecentlyClosedTab( /** A host replay the renderer has already seen; a replayed `done` that differs from the current row * ended while the host was unreachable, so it is not a replay of anything this renderer saw. */ export function isSeenHostReplay( - data: Pick, + data: Pick< + AgentStatusIpcPayload, + 'isReplay' | 'stateStartedAt' | 'turnCompletedAt' | 'turnEndedAt' + >, state: string, - existing: Pick | undefined + existing: + | Pick + | undefined ): boolean { if (data.isReplay !== true) { return false @@ -46,9 +51,12 @@ export function isSeenHostReplay( if (state === 'working' && Number.isFinite(data.turnCompletedAt)) { return false } + // Why: the same done segment can hold a later turn end (a Stop re-entered while unreachable). return ( state !== 'done' || - (existing.state === 'done' && existing.stateStartedAt === data.stateStartedAt) + (existing.state === 'done' && + existing.stateStartedAt === data.stateStartedAt && + existing.turnEndedAt === data.turnEndedAt) ) } diff --git a/src/renderer/src/hooks/pane-key-pty-id.ts b/src/renderer/src/hooks/pane-key-pty-id.ts new file mode 100644 index 00000000000..e0464590a2e --- /dev/null +++ b/src/renderer/src/hooks/pane-key-pty-id.ts @@ -0,0 +1,41 @@ +import { useAppStore } from '@/store' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import { collectLeafIdsInOrder } from '@/components/terminal-pane/layout-serialization' + +export function getPtyIdForPaneKey(paneKey: string): string | null { + const parsed = parsePaneKey(paneKey) + if (!parsed) { + return null + } + const state = useAppStore.getState() + const tabPtyIds = state.ptyIdsByTabId?.[parsed.tabId] + if (!tabPtyIds || tabPtyIds.length === 0) { + return null + } + // Why: split-pane leaves share one tab-level pty list, so a tab-level lookup + // would return a sibling's pty for an already-closed leaf and let a late + // 'done' hook event fire a spurious notification. Resolve liveness through + // the leaf-keyed binding maintained by syncPanePtyLayoutBinding, which + // deletes the entry when the leaf closes. + const layout = state.terminalLayoutsByTabId?.[parsed.tabId] + const ptyIdsByLeafId = layout?.ptyIdsByLeafId + if (ptyIdsByLeafId) { + const leafPtyId = ptyIdsByLeafId[parsed.leafId] + if (leafPtyId && tabPtyIds.includes(leafPtyId)) { + return leafPtyId + } + if (!layout?.root) { + // Why: inactive worktree switches can temporarily preserve only tab-level + // PTY liveness; do not drop hook completions just because layout metadata + // is at the empty snapshot. + return tabPtyIds[0] ?? null + } + // Why: switching worktrees can unmount the terminal pane and clear the + // leaf binding before the hook completion arrives, while the tab PTY is + // still live. Keep closed leaves suppressed by requiring the leaf in layout. + return collectLeafIdsInOrder(layout.root).includes(parsed.leafId) + ? (tabPtyIds[0] ?? null) + : null + } + return tabPtyIds[0] ?? null +} diff --git a/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts b/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts index 25c9730a016..edb43449352 100644 --- a/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts @@ -31,6 +31,8 @@ function referenceProjection(map: AppState['agentStatusByPaneKey']): string { stateStartedAt: entry.stateStartedAt, // A new turn can keep the state (child work held it open); its stamp alone must republish. turnStartedAt: entry.turnStartedAt ?? null, + turnEndedAt: entry.turnEndedAt ?? null, + turnEndDisposition: entry.turnEndDisposition ?? null, agentType: entry.agentType ?? null, terminalTitle: entry.terminalTitle ?? null, stateHistory: entry.stateHistory.map((history) => ({ diff --git a/src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts b/src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts index b67cf5f6f37..8797b917501 100644 --- a/src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts @@ -112,6 +112,8 @@ function referenceAgentStatusProjection(map: AppState['agentStatusByPaneKey']): updatedAtBucket: Math.floor(entry.updatedAt / AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS), stateStartedAt: entry.stateStartedAt, turnStartedAt: entry.turnStartedAt ?? null, + turnEndedAt: entry.turnEndedAt ?? null, + turnEndDisposition: entry.turnEndDisposition ?? null, agentType: entry.agentType ?? null, terminalTitle: entry.terminalTitle ?? null, stateHistory: entry.stateHistory.map((history) => ({ diff --git a/src/renderer/src/runtime/sync-runtime-graph/agent-status-projection.ts b/src/renderer/src/runtime/sync-runtime-graph/agent-status-projection.ts index 81e12ad9a59..c79dc249225 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/agent-status-projection.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/agent-status-projection.ts @@ -33,6 +33,8 @@ function serializeAgentStatusEntry( updatedAtBucket: Math.floor(entry.updatedAt / AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS), stateStartedAt: entry.stateStartedAt, turnStartedAt: entry.turnStartedAt ?? null, + turnEndedAt: entry.turnEndedAt ?? null, + turnEndDisposition: entry.turnEndDisposition ?? null, agentType: entry.agentType ?? null, terminalTitle: entry.terminalTitle ?? null, stateHistory: entry.stateHistory.map((history) => ({ diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.ts b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.ts index de7c54a3b62..6f7a749b236 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.ts @@ -50,6 +50,8 @@ export function agentStatusEntryEqual( a.updatedAt === b.updatedAt && a.stateStartedAt === b.stateStartedAt && a.turnStartedAt === b.turnStartedAt && + a.turnEndedAt === b.turnEndedAt && + a.turnEndDisposition === b.turnEndDisposition && a.agentType === b.agentType && a.paneKey === b.paneKey && a.worktreeId === b.worktreeId && diff --git a/src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts b/src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts index 447bf7d22e7..8cc352bfdab 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts @@ -1,7 +1,11 @@ import type { AppState } from '../../store' import { useAppStore } from '../../store' import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' -import { pickParsedAgentStatusPayload } from '../../../../shared/agent-status-types' +import { + pickHostTurnEnd, + pickParsedAgentStatusPayload, + type TurnEndDisposition +} from '../../../../shared/agent-status-types' import { normalizeTurnCompletedAtField } from '../../../../shared/agent-status-field-normalization' import { isSeenHostReplay } from '@/hooks/ipc-events/agent-status-routing' import { isClientAuthoritativeAgentStatusPane } from '@/components/terminal-pane/renderer-owned-agent-status-registry' @@ -35,6 +39,8 @@ export function applyWebSessionTabsStorePatch( seedOnly?: true payload: ReturnType & { stateStartedAt: number + turnEndedAt?: number + turnEndDisposition?: TurnEndDisposition localStateStartedAt?: number } }[] = [] @@ -135,7 +141,8 @@ export function applyWebSessionTabsStorePatch( { isReplay: true, stateStartedAt: notificationStatus.stateStartedAt, - turnCompletedAt + turnCompletedAt, + turnEndedAt: notificationStatus.turnEndedAt }, notificationStatus.state, state.agentStatusByPaneKey[notificationStatus.paneKey] @@ -153,6 +160,7 @@ export function applyWebSessionTabsStorePatch( ...(turnCompletedAt !== undefined ? { turnCompletedAt } : {}) }), stateStartedAt: notificationStatus.stateStartedAt, + ...pickHostTurnEnd(notificationStatus), ...(clientOwnedNotification ? { localStateStartedAt } : {}) } }) diff --git a/src/renderer/src/store/slices/agent-status-contract.ts b/src/renderer/src/store/slices/agent-status-contract.ts index 6d79f401340..28e65e19fbc 100644 --- a/src/renderer/src/store/slices/agent-status-contract.ts +++ b/src/renderer/src/store/slices/agent-status-contract.ts @@ -5,7 +5,8 @@ import type { AgentStatusOrchestrationContext, AgentType, MigrationUnsupportedPtyEntry, - ParsedAgentStatusPayload + ParsedAgentStatusPayload, + TurnEndDisposition } from '../../../../shared/agent-status-types' import type { AgentStatusObservation } from '../../../../shared/agent-status-observation' import type { ProgramStatusFacet } from '../../../../shared/program-status/facet' @@ -95,6 +96,9 @@ export type AgentStatusTiming = { stateStartedAt?: number /** The host's turn start; see `AgentStatusEntry.turnStartedAt`. */ turnStartedAt?: number + /** The host's turn end; see `AgentStatusEntry.turnEndedAt`. */ + turnEndedAt?: number + turnEndDisposition?: TurnEndDisposition } export type AgentStatusRouting = { diff --git a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts index e8e0cf57538..53562e15ca8 100644 --- a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts +++ b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts @@ -2,9 +2,10 @@ import { sameAgentStatusTerminalOwner } from '../../../../shared/agent-status-te import type { AppState } from '../types' import { resolveAgentStatusLiveEntryMainAgent } from './agent-status-live-entry-main-agent' import { resolveAgentStatusLiveEntryStateHistory } from '../../../../shared/agent-status-live-entry-state-history' -import type { - MigrationUnsupportedPtyEntry, - AgentStatusEntry +import { + pickHostTurnEnd, + type MigrationUnsupportedPtyEntry, + type AgentStatusEntry } from '../../../../shared/agent-status-types' import { agentProviderSessionsEqual, @@ -127,6 +128,13 @@ export function buildAgentStatusLiveEntry( const turnStartedAt = timing?.turnStartedAt ?? (existing && existing.state === payload.state ? existing.turnStartedAt : undefined) + // Why: the end stamp and its disposition are one host fact, inherited together. + const turnEnd = + timing?.turnEndedAt !== undefined || timing?.turnEndDisposition !== undefined + ? pickHostTurnEnd(timing) + : existing && existing.state === payload.state + ? pickHostTurnEnd(existing) + : {} if ( existing && shouldSuppressInheritedTerminalStatus({ @@ -216,6 +224,7 @@ export function buildAgentStatusLiveEntry( stateStartedAt, stateObservedAt, ...(turnStartedAt !== undefined ? { turnStartedAt } : {}), + ...turnEnd, agentType: identity.agentType, model: payload.model ?? (existing?.agentType === identity.agentType ? existing.model : undefined), diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 3658235a23b..a067e24e4b1 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -329,6 +329,7 @@ export function normalizeHookPayload( : {}), ...(providerSession ? { providerSession } : {}), ...(providerSessionOnly ? { providerSessionOnly: true } : {}), + ...dispatched.hostTurnSignals, payload: transportPayload } } diff --git a/src/shared/agent-hook-listener/listener-event.ts b/src/shared/agent-hook-listener/listener-event.ts index df67ac30ccb..e52ee5536fd 100644 --- a/src/shared/agent-hook-listener/listener-event.ts +++ b/src/shared/agent-hook-listener/listener-event.ts @@ -1,9 +1,20 @@ import type { AgentProcessPresence } from '../agent-process-presence' -import type { ParsedAgentStatusPayload } from '../agent-status-types' +import type { ParsedAgentStatusPayload, TurnEndDisposition } from '../agent-status-types' import type { AgentHookSource } from '../agent-hook-relay' import type { AgentProviderSessionMetadata } from '../agent-session-resume' import type { ClaudeSettledRequest } from './claude-settled-permission-requests' +/** + * The execution host's private record of which turn a pane's agent session is in and which turn + * last ended. Saved with the status row; never published (the public fact is `turnEndedAt`). + */ +export type HostTurnRecord = { + /** The provider session the turn keys belong to; another session starts a new record. */ + owner: string + currentTurnKey: string + completed?: { turnKey: string; endedAt: number } +} + export type AgentHookEventPayload = { paneKey: string agentPresence?: AgentProcessPresence @@ -48,6 +59,20 @@ export type AgentHookEventPayload = { providerSessionOnly?: boolean /** True when this event is a relay cache replay rather than a live hook. */ isReplay?: boolean + /** When the main agent's latest turn ended, minted only by the execution host's turn-end + * derivation (agent-hook-turn-end-timing.ts); a relay forwards it and a result retry restates it. */ + turnEndedAt?: number + /** How the host identified that end (agent-hook-turn-end-timing.ts); forwarded and restated with it. */ + turnEndDisposition?: TurnEndDisposition + /** The agent's turn-end event re-entered after a blocking Stop hook (`stop_hook_active`), read + * only for agents whose signal is verified. In-process only; never stored or sent. */ + stopHookActive?: true + /** The agent's own turn id (Codex `turn_id`, Command Code's transcript prompt key). In-process + * only: the host folds it into `hostTurn`; never stored or sent. */ + nativeTurnKey?: string + /** The execution host's private turn record (agent-hook-turn-end-timing.ts). Saved with the row, + * never published: snapshots, IPC payloads and relay envelopes are built from allow-lists. */ + hostTurn?: HostTurnRecord /** Claude live work the child list does not show (a shell, a cron, an owed task notification); rejects false input-based interrupts. */ claudeRunningNonAgentTask?: boolean /** Row projected from a structured session the host holds: `owned` while its provider child diff --git a/src/shared/agent-hook-listener/provider-dispatch.ts b/src/shared/agent-hook-listener/provider-dispatch.ts index 142858ca3f8..a0c929dd767 100644 --- a/src/shared/agent-hook-listener/provider-dispatch.ts +++ b/src/shared/agent-hook-listener/provider-dispatch.ts @@ -2,6 +2,8 @@ import { normalizeCompatibleLifecycleEvent } from './providers/compatible-lifecy import { normalizeQoderEvent } from './providers/qoder-events' import type { AgentMainAgentStatus, ParsedAgentStatusPayload } from '../agent-status-types' import type { AgentHookSource } from '../agent-hook-relay' +import { readHostTurnSignals } from '../agent-hook-turn-end-timing' +import type { AgentHookEventPayload } from './listener-event' import { readLastCommandCodeUserPromptEntryFromTranscript } from './command-code-transcript' import { readGrokHomeEnvelope } from './grok-result-discovery' import { readFirstString } from './interactive-tool' @@ -38,6 +40,7 @@ export type ProviderDispatchResult = { hasTranscriptPromptEvidence: boolean /** The event the payload was normalized as, which a provider may restate (Codex ended turns). */ eventName: unknown + hostTurnSignals: Pick } /** Exhaustive provider routing with provider-specific transcript locality and attribution. */ @@ -223,6 +226,7 @@ export function normalizeProviderEvent(input: { resolvedPromptText, promptInteractionKey, hasTranscriptPromptEvidence, - eventName + eventName, + hostTurnSignals: readHostTurnSignals(source, hookPayload, promptInteractionKey) } } diff --git a/src/shared/agent-hook-listener/provider-event-routing.ts b/src/shared/agent-hook-listener/provider-event-routing.ts index 53d360cd061..e55fdaf578f 100644 --- a/src/shared/agent-hook-listener/provider-event-routing.ts +++ b/src/shared/agent-hook-listener/provider-event-routing.ts @@ -94,6 +94,55 @@ export function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boo } } +/** The per-provider answer to "is this the main agent's own turn-end event?". Idle reminders, + * session events and late enrichment restate a finished turn, so they are never one. */ +export function isTurnEndEvent(source: AgentHookSource, eventName: unknown): boolean { + // Why: exhaustive switch so a new AgentHookSource fails typecheck here instead of falling through to false. + switch (source) { + case 'claude': + case 'qoder': + case 'qoder-cn': + case 'qwen-code': + case 'codebuddy': + case 'kimi': + case 'muse': + return eventName === 'Stop' || eventName === 'StopFailure' + case 'dsh': + case 'zcode': + case 'codex': + case 'antigravity': + case 'droid': + case 'devin': + return eventName === 'Stop' + case 'gemini': + return eventName === 'AfterAgent' + case 'amp': + return eventName === 'agent.end' + case 'command-code': + // Why: its only done event; repeats within one prompt share the transcript key, so they restate. + return eventName === 'Stop' + case 'cursor': + case 'kiro': + return eventName === 'stop' + case 'grok': + return isGrokEvent(eventName, 'stop', 'stop_failure', 'stop_cancelled') + case 'copilot': + return normalizeCopilotEventName(eventName) === 'Stop' + case 'hermes': + return eventName === 'post_llm_call' + case 'jcode': + return eventName === 'turn_end' + case 'opencode': + case 'opencode2': + case 'mimo-code': + case 'pi': + case 'omp': + case 'prime-agent': + // Why: their done events are status snapshots or milestones that can repeat within one turn. + return false + } +} + export function hasExplicitUserPrompt( source: AgentHookSource, eventName: unknown, diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index c98645aea5c..b457a227015 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -26,7 +26,11 @@ import type { ClaudeSettledRequest } from './agent-hook-listener/claude-settled- import { createHash } from 'node:crypto' -import type { AgentSubagentSnapshot, ParsedAgentStatusPayload } from './agent-status-types' +import type { + AgentSubagentSnapshot, + ParsedAgentStatusPayload, + TurnEndDisposition +} from './agent-status-types' import type { AgentProviderSessionMetadata } from './agent-session-resume' import type { AgentHookTarget } from './agent-hook-types' @@ -122,6 +126,10 @@ export type AgentHookRelayEnvelope = { providerSessionOnly?: boolean /** True when the relay is replaying its cache after Orca reconnects. */ isReplay?: boolean + /** The relay's turn-end stamp (see AgentHookEventPayload.turnEndedAt); older relays omit it. */ + turnEndedAt?: number + /** How the relay identified that end; older relays omit it (legacy). */ + turnEndDisposition?: TurnEndDisposition /** Claude live work the child list does not show (a shell, a cron, an owed task notification), for input-interrupt inference on the receiving host. */ claudeRunningNonAgentTask?: boolean /** Forwarded from the agent CLI POST body. The relay default is `remote`, diff --git a/src/shared/agent-hook-turn-end-timing.ts b/src/shared/agent-hook-turn-end-timing.ts new file mode 100644 index 00000000000..7e55264a969 --- /dev/null +++ b/src/shared/agent-hook-turn-end-timing.ts @@ -0,0 +1,165 @@ +import type { AgentHookEventPayload, HostTurnRecord } from './agent-hook-listener/listener-event' +import { isNewTurnEvent, isTurnEndEvent } from './agent-hook-listener/provider-event-routing' +import { readString } from './agent-hook-listener/tool-input-preview' +import type { AgentHookSource } from './agent-hook-relay' +import { agentMainAgentVerdict } from './agent-main-agent-verdict' +import { agentReportsStopHookContinuation } from './agent-stop-hook-continuation' +import { pickHostTurnEnd, type HostTurnEndFacts } from './agent-status-types' + +type TurnEndTimingRow = Pick< + AgentHookEventPayload, + 'turnEndedAt' | 'turnEndDisposition' | 'payload' | 'hostTurn' +> + +type HostTurnEnd = HostTurnEndFacts & { hostTurn?: HostTurnRecord } + +// Why: a record with no verified opening or native key yet (a new session, a pre-record row). +const UNOPENED_TURN = 'seed' + +/** The hook body's turn facts the host folds into its turn record; in-process only. */ +export function readHostTurnSignals( + source: AgentHookSource, + hookPayload: Record, + promptInteractionKey: string | undefined +): Pick { + // Why: the agent's own turn id tells a new turn from a re-delivery even when no prompt hook + // ran (Codex turns woken by agent mail, every Command Code turn). + const nativeTurnKey = + source === 'codex' + ? readString(hookPayload, 'turn_id') + : source === 'command-code' + ? promptInteractionKey + : undefined + return { + ...(hookPayload.stop_hook_active === true && agentReportsStopHookContinuation(source) + ? { stopHookActive: true } + : {}), + ...(nativeTurnKey ? { nativeTurnKey } : {}) + } +} + +// Why: a settled cancellation ends the open turn, so that turn's late end is a repeat, not an end. +function endsTurnByCancellation(row: TurnEndTimingRow | undefined): boolean { + return ( + row?.payload.state === 'done' && + row.turnEndDisposition === undefined && + agentMainAgentVerdict(row.payload) === 'cancellation' + ) +} + +export function isTurnEndedAt(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 +} + +function resolveOwner( + previous: HostTurnRecord | undefined, + event: AgentHookEventPayload & { source: NonNullable } +): { owner: string; replaced: boolean } { + const anonymous = `${event.source}:` + const sessionId = event.providerSession?.id + if (!sessionId) { + // Why: an event that names no session belongs to whichever session of this agent is current. + return previous?.owner.startsWith(anonymous) + ? { owner: previous.owner, replaced: false } + : { owner: anonymous, replaced: previous !== undefined } + } + const owner = `${anonymous}${sessionId}` + return { + owner, + replaced: previous !== undefined && previous.owner !== owner && previous.owner !== anonymous + } +} + +/** + * The one turn-end derivation every execution host runs (local main and the WSL relay). A turn + * advances on the agent's native turn key (Codex `turn_id`, Command Code's transcript position) + * or, without one, only on a prompt-opening event. A live root turn-end event is an identified + * end, minted, when its turn is unused or a verified continuation re-entered after a blocking Stop + * hook. A same-turn repeat is a proven duplicate only for an agent with both a native key and a + * verified continuation flag; otherwise it is ambiguous (a repeat, or a turn whose opening never + * arrived) and never mints. Restatements (replay, retry, idle reminder, enrichment) keep the end + * shown in the current done segment. Keys are opaque: only compared for equality. + */ +export function reduceHostTurnEnd( + previous: TurnEndTimingRow | undefined, + event: AgentHookEventPayload, + at: number, + canMint: boolean +): HostTurnEnd { + const shown = previous?.payload.state === 'done' ? pickHostTurnEnd(previous) : {} + const done = event.payload.state === 'done' + // Why: a relay, a result retry or a replay already decided this event's end; the host restates it, + // except over a done segment it shows with no end of its own (a cancel it inferred), which keeps none. + if (!canMint || event.isReplay === true || event.turnEndDisposition !== undefined) { + const shownWithoutEnd = + previous?.payload.state === 'done' && previous.turnEndDisposition === undefined + return { + ...(done + ? event.turnEndDisposition !== undefined && !shownWithoutEnd + ? pickHostTurnEnd(event) + : shown + : {}), + ...(previous?.hostTurn ? { hostTurn: previous.hostTurn } : {}) + } + } + let record = previous?.hostTurn + const source = event.source + const isLiveRoot = + source !== undefined && event.toolAgentId === undefined && event.providerSessionOnly !== true + if (isLiveRoot) { + const { owner, replaced } = resolveOwner(record, { ...event, source }) + if (!record || replaced) { + record = { owner, currentTurnKey: UNOPENED_TURN } + } else if (record.owner !== owner) { + record = { ...record, owner } + } + if (event.nativeTurnKey) { + record = { ...record, currentTurnKey: `turn:${event.nativeTurnKey}` } + } else if ( + isNewTurnEvent(source, event.hookEventName) && + event.payload.sessionBoundary !== true + ) { + record = { ...record, currentTurnKey: `opened:${at}` } + } + } + const kept = record ? { hostTurn: record } : {} + if (!done) { + return kept + } + if ( + !record || + !isLiveRoot || + event.payload.sessionBoundary === true || + !isTurnEndEvent(source, event.hookEventName) + ) { + if ( + record && + record.completed?.turnKey !== record.currentTurnKey && + endsTurnByCancellation(event) + ) { + const completed = { turnKey: record.currentTurnKey, endedAt: at } + return { ...shown, hostTurn: { ...record, completed } } + } + return { ...shown, ...kept } + } + const completed = record.completed + const sameTurn = completed?.turnKey === record.currentTurnKey + if (event.stopHookActive === true || (!sameTurn && record.currentTurnKey !== UNOPENED_TURN)) { + const endedAt = Math.max(at, (completed?.endedAt ?? 0) + 1) + return { + turnEndedAt: endedAt, + turnEndDisposition: 'identified', + hostTurn: { ...record, completed: { turnKey: record.currentTurnKey, endedAt } } + } + } + // Why: the agent's own turn id plus a verified continuation flag mean an unflagged repeat of + // this turn's Stop is a duplicate delivery (Codex re-fires it flagged for a continuation). + if (completed && sameTurn && event.nativeTurnKey && agentReportsStopHookContinuation(source)) { + return { turnEndedAt: completed.endedAt, turnEndDisposition: 'identified', ...kept } + } + return { + ...(shown.turnEndedAt !== undefined ? { turnEndedAt: shown.turnEndedAt } : {}), + turnEndDisposition: 'ambiguous', + ...kept + } +} diff --git a/src/shared/agent-status-ipc-payload.ts b/src/shared/agent-status-ipc-payload.ts index 95032db5f94..870fee66471 100644 --- a/src/shared/agent-status-ipc-payload.ts +++ b/src/shared/agent-status-ipc-payload.ts @@ -14,7 +14,8 @@ import type { } from './agent-status-run' import type { AgentStatusOrchestrationContext, - ParsedAgentStatusPayload + ParsedAgentStatusPayload, + TurnEndDisposition } from './agent-status-types' /** A PTY the pane-key migration could not move, reported for operator triage. */ @@ -60,6 +61,11 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & { * own turn-opening event. Optional: old hosts and turns opened unseen omit it, and readers fall * back to `stateStartedAt`. */ turnStartedAt?: number + /** When the main agent's latest turn ended, stamped by the execution host from the agent's own + * turn-end event and kept through restatements. Absent from old hosts and unclassified agents. */ + turnEndedAt?: number + /** How the host identified that end; absent from old hosts (legacy). */ + turnEndDisposition?: TurnEndDisposition orchestration?: AgentStatusOrchestrationContext providerSession?: AgentProviderSessionMetadata /** Resume identity update only; the status-shaped fields are transport placeholders. */ diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 9e9827884fb..a0c353a6083 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -98,6 +98,10 @@ export type AgentStatusEntry = { /** When the main agent's current turn began, as the hook server stamped it. Absent from old hosts * and writers without a turn clock; readers fall back to `stateStartedAt`. */ turnStartedAt?: number + /** The host's turn-end stamp; see `AgentStatusIpcPayload.turnEndedAt`. */ + turnEndedAt?: number + /** How the host identified that end; see `TurnEndDisposition`. */ + turnEndDisposition?: TurnEndDisposition agentType?: AgentType /** Provider model currently used by this session. */ model?: string @@ -275,6 +279,25 @@ export { // Why: ReadonlySet so .has() accepts any string without a cast here; the narrowing cast stays on the return line where it's proven safe. const VALID_STATES: ReadonlySet = new Set(AGENT_STATUS_STATES) +/** How the execution host identified the current turn end: `identified` (a verified new turn or + * continuation, or a proven repeat of one) or `ambiguous` (a repeat it can't tell from a turn whose + * opening it missed). Absent: legacy (old hosts, replays, unclassified agents). */ +export type TurnEndDisposition = 'identified' | 'ambiguous' + +export type HostTurnEndFacts = { turnEndedAt?: number; turnEndDisposition?: TurnEndDisposition } + +export function isTurnEndDisposition(value: unknown): value is TurnEndDisposition { + return value === 'identified' || value === 'ambiguous' +} + +/** The host's turn-end facts of a row, for the copies that carry them. */ +export function pickHostTurnEnd(row: HostTurnEndFacts): HostTurnEndFacts { + return { + ...(row.turnEndedAt !== undefined ? { turnEndedAt: row.turnEndedAt } : {}), + ...(row.turnEndDisposition !== undefined ? { turnEndDisposition: row.turnEndDisposition } : {}) + } +} + export function isAgentStatusState(value: unknown): value is AgentStatusState { return typeof value === 'string' && VALID_STATES.has(value) } diff --git a/src/shared/agent-stop-hook-continuation.ts b/src/shared/agent-stop-hook-continuation.ts new file mode 100644 index 00000000000..bf6042872dc --- /dev/null +++ b/src/shared/agent-stop-hook-continuation.ts @@ -0,0 +1,8 @@ +// Why: only these agents' re-entered Stop (`stop_hook_active`) is verified first-hand (Claude by +// lane capture, Codex by source). The host reads no other agent's flag, so their repeats stay +// ambiguous ends. +const STOP_HOOK_CONTINUATION_AGENTS: ReadonlySet = new Set(['claude', 'codex']) + +export function agentReportsStopHookContinuation(agent: string | null | undefined): boolean { + return agent !== null && agent !== undefined && STOP_HOOK_CONTINUATION_AGENTS.has(agent) +} diff --git a/src/shared/notification-burst-cooldown.ts b/src/shared/notification-burst-cooldown.ts index e7616c57746..852d4ede44a 100644 --- a/src/shared/notification-burst-cooldown.ts +++ b/src/shared/notification-burst-cooldown.ts @@ -1,4 +1,4 @@ -const NOTIFICATION_COOLDOWN_MS = 5000 +export const NOTIFICATION_COOLDOWN_MS = 5000 const MAX_RECENT_NOTIFICATION_KEYS = 50 function pruneRecentNotifications(recentNotifications: Map, now: number): void { diff --git a/src/shared/terminal-runtime-capabilities.ts b/src/shared/terminal-runtime-capabilities.ts index 3eb0a8f34f2..6aaabd38d23 100644 --- a/src/shared/terminal-runtime-capabilities.ts +++ b/src/shared/terminal-runtime-capabilities.ts @@ -11,6 +11,9 @@ export const TERMINAL_PROMPT_DELIVERY_RUNTIME_CAPABILITY = 'terminal.prompt-deli // Why: paired clients may unmount xterm only when the host can return a // bounded, sequenced scrollback snapshot for lossless reveal. export const TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY = 'terminal.paired-parking.v1' as const +// Why: a host without it can't tell a later turn end from a restatement, so clients keep main's +// terminal-title completion behavior for its panes. +export const AGENT_STATUS_TURN_END_RUNTIME_CAPABILITY = 'agent-status.turn-ended-at.v1' as const // Why: terminal.ensureAgentSession params are strict, so an older host rejects `spawnIntent`; a // host that advertises this also may answer a declared restore with `terminal_spawn_deferred`. export const TERMINAL_SPAWN_INTENT_RUNTIME_CAPABILITY = 'terminal.spawn-intent.v1' as const @@ -30,6 +33,7 @@ export const TERMINAL_RUNTIME_CAPABILITIES = [ TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY, TERMINAL_PROMPT_DELIVERY_RUNTIME_CAPABILITY, TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY, + AGENT_STATUS_TURN_END_RUNTIME_CAPABILITY, TERMINAL_SPAWN_INTENT_RUNTIME_CAPABILITY, ...TERMINAL_QUICK_COMMAND_RUNTIME_CAPABILITIES, TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, diff --git a/src/wsl-guest/agent-hook-envelope-build.ts b/src/wsl-guest/agent-hook-envelope-build.ts index 1a11c0d9cea..a3873ebe66f 100644 --- a/src/wsl-guest/agent-hook-envelope-build.ts +++ b/src/wsl-guest/agent-hook-envelope-build.ts @@ -3,6 +3,7 @@ // redelivery of the same envelope. import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' import type { AgentHookRelayEnvelope, AgentHookSource } from '../shared/agent-hook-relay' +import { pickHostTurnEnd } from '../shared/agent-status-types' // Why: cap metadata to prevent a misbehaving CLI growing the cache unboundedly. const MAX_HOOK_META_LEN = 64 @@ -40,6 +41,7 @@ export function buildRelayHookEnvelope( ...(event.providerSession ? { providerSession: event.providerSession } : {}), ...(event.providerSessionOnly ? { providerSessionOnly: true } : {}), isReplay: options.isReplay === true ? true : undefined, + ...pickHostTurnEnd(event), env, version, payload: event.payload diff --git a/src/wsl-guest/agent-hook-event-admission.ts b/src/wsl-guest/agent-hook-event-admission.ts index 7b9701b2f33..27f95e8ad58 100644 --- a/src/wsl-guest/agent-hook-event-admission.ts +++ b/src/wsl-guest/agent-hook-event-admission.ts @@ -6,6 +6,7 @@ import type { HookListenerState } from '../shared/agent-hook-listener/listener-s import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' import type { AgentHookSource } from '../shared/agent-hook-relay' import { retainClaudeSettledRequests } from '../shared/agent-hook-listener/claude-settled-permission-requests' +import { reduceHostTurnEnd } from '../shared/agent-hook-turn-end-timing' import { buildRelayHookEnvelope } from './agent-hook-envelope-build' import type { RelayHookForward } from './agent-hook-server-contract' import { MAX_CACHED_PANES, type CachedPaneEnvelopeMeta } from './agent-hook-cached-pane-status' @@ -53,13 +54,26 @@ export function applyRelayHookEvent( if (!transitioned) { return undefined } - const event = withRelayClaudeTurnRevision( + const revised = withRelayClaudeTurnRevision( previous, transitioned.agentPresence?.ended ? { ...transitioned, providerSessionOnly: true } : transitioned, source ) + // Why: the relay is this pane's execution host, the only side that sees the event name. + const { + stopHookActive: _stopHookActive, + nativeTurnKey: _nativeTurnKey, + turnEndedAt: _turnEndedAt, + turnEndDisposition: _turnEndDisposition, + hostTurn: _hostTurn, + ...unflagged + } = revised + const event: AgentHookEventPayload = { + ...unflagged, + ...reduceHostTurnEnd(previous, revised, Date.now(), options.isReplay !== true) + } // Why: this post came from a process still running inside a pane whose tab the user closed. // Caching or forwarding it makes every connected client advertise a live, resumable agent pane // that no tab owns — the advertisement that ends up auto-typing a second `--resume` onto a diff --git a/src/wsl-guest/agent-hook-interrupt-reconciliation.ts b/src/wsl-guest/agent-hook-interrupt-reconciliation.ts index 7abac4e496b..5eb99a69223 100644 --- a/src/wsl-guest/agent-hook-interrupt-reconciliation.ts +++ b/src/wsl-guest/agent-hook-interrupt-reconciliation.ts @@ -77,6 +77,7 @@ export function applyRelayClaudeInterrupt( { ...row, hookEventName: undefined, + toolAgentId: undefined, hasExplicitPrompt: undefined, hostEvidenceObservedAt: Date.now(), claudeRunningNonAgentTask: claudeRowHasUnlistedLiveWork(host.state, row.paneKey), diff --git a/src/wsl-guest/agent-hook-result-retry-scheduler.ts b/src/wsl-guest/agent-hook-result-retry-scheduler.ts index 242ac1e37d8..1e56f8eac9a 100644 --- a/src/wsl-guest/agent-hook-result-retry-scheduler.ts +++ b/src/wsl-guest/agent-hook-result-retry-scheduler.ts @@ -11,6 +11,7 @@ import { normalizeHookPayload } from '../shared/agent-hook-listener' import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' import type { HookListenerState } from '../shared/agent-hook-listener/listener-state' import type { AgentHookSource } from '../shared/agent-hook-relay' +import { pickHostTurnEnd } from '../shared/agent-status-types' import { shouldPollHookTranscript, hookTranscriptWatchPath, @@ -240,6 +241,7 @@ export class AgentHookResultRetryScheduler { ) return } - this.host.applyEvent(event, source, env, version) + // Why: the retry only adds text to the same turn end, so it restates that end instead of minting one. + this.host.applyEvent({ ...event, ...pickHostTurnEnd(current) }, source, env, version) } } diff --git a/src/wsl-guest/agent-hook-server.test.ts b/src/wsl-guest/agent-hook-server.test.ts index 3ea7609b9d7..bf4569d22f9 100644 --- a/src/wsl-guest/agent-hook-server.test.ts +++ b/src/wsl-guest/agent-hook-server.test.ts @@ -583,6 +583,50 @@ describe('RelayAgentHookServer', () => { expect(forward.mock.calls.at(-1)?.[0].payload.lastAssistantMessage).toBe( 'Relay transcript completed.' ) + // A Stop whose opening the relay never saw is ambiguous; SessionEnd and the retry keep that. + const ends = forward.mock.calls.map(([env]) => `${env.turnEndedAt}:${env.turnEndDisposition}`) + expect(new Set(ends)).toEqual(new Set(['undefined:ambiguous'])) + } finally { + server.stop() + } + }) + + it('marks a verified re-entered Stop as a later end, an unproven repeat ambiguous, and replays both unchanged', async () => { + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + await server.start() + try { + const { port, token } = server.getCoordinates() + const post = (payload: Record) => + fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Orca-Agent-Hook-Token': token }, + body: JSON.stringify({ + paneKey: PANE_KEY, + tabId: 'tab-1', + env: 'remote', + version: '1', + payload + }) + }) + await post({ hook_event_name: 'UserPromptSubmit', prompt: 'Lint it' }) + await post({ hook_event_name: 'Stop', last_assistant_message: 'Draft.' }) + await new Promise((resolve) => setTimeout(resolve, 5)) + // Claude re-fires Stop with the flag when a blocking Stop hook continues the same turn. + await post({ + hook_event_name: 'Stop', + stop_hook_active: true, + last_assistant_message: 'Final.' + }) + const [draft, final] = forward.mock.calls.slice(-2).map(([env]) => env.turnEndedAt) + expect(final).toBeGreaterThan(draft!) + await post({ hook_event_name: 'Stop', last_assistant_message: 'Final.' }) + const repeat = { turnEndedAt: final, turnEndDisposition: 'ambiguous' } + expect(forward.mock.calls.at(-1)?.[0]).toMatchObject(repeat) + + expect(server.replayCachedPayloadsForPanes()).toBe(1) + expect(forward.mock.calls.at(-1)?.[0]).toMatchObject({ isReplay: true, ...repeat }) + expect(forward.mock.calls.at(-1)?.[0]).not.toHaveProperty('hostTurn') } finally { server.stop() }