mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(notifications): a pane with hooks gets its finished banner from its hook turn only (#30620)
* fix(notifications): tie a completion banner to the hook turn it announces, not a 10 s window A title-only working->idle edge (Claude's /btw side question fires no hook) raised a generic "Task complete" banner because the pane's hook row was older than 10 s. On a hook-reporting pane the hook row now decides: a row not yet announced supplies the banner content however old it is; an already-announced row means no turn ended, so no banner. Hookless agents keep the generic title fallback. * fix(notifications): a pane with hooks gets its completion banner from its hook turn only The title lanes (mounted pane and parked watcher) announced a finished task on every working->idle title edge, even on panes whose agent reports turns through hooks. A title spin that ends no hook turn (Claude's /btw side question, Codex's /compact) raised an extra banner that could only be filled with the workspace name and tab title. On a pane the hook lane is tracking, with a settled Done row from the same agent, the title lane now defers before it claims the turn, so the hook lane's banner (with the last message) is the only one. A BEL in the same burst waits on the hook lane's pending Done. Hookless agents, restored rows, hooks switched off and the post-restart re-arm keep the title banner. This replaces the previous commit's announcement memory in the dispatcher. * fix(notifications): hook-owned turn ends settle pane bookkeeping and announce re-entered Stops - A title idle edge that defers to the hook lane now settles the pane's turn, so quitting the agent after a /btw no longer raises a generic "Task complete" banner. - A BEL held while the hook lane's Done is pending is superseded by that Done at fire time instead of being held until the next turn start. - A Stop re-entered after a blocking Stop hook carries its new final message in its identity, so the hook lane announces it instead of deduping it against the first Stop. - Title lanes ask the hook lane whether it can announce the turn end (live, agent evidence, not re-arming) instead of copying its conditions, and fall back when the agent's own hooks are switched off. - The hook lane's non-quiet Done forwards its own snapshot, so its banner content no longer depends on the stored row's age. - The whole-flow test now drives the real pane wiring, the real hook lane and the real dispatcher. * fix(notifications): the host marks each real turn end, so a repeated status can't raise a second banner The renderer told a Stop re-entered after a blocking Stop hook from a restatement by its final message. The message also changes when the listener's cache resets (a session reset, then an idle reminder), so a restated finished turn raised a second, empty "finished" banner. The execution host now classifies each provider's own turn-end event by name and stamps an optional `turnEndedAt` on the status row. Idle reminders, session events, replays, child re-emits and result retries restate it; a turn-end event on an already finished row is a new end only when the agent says it re-entered (`stop_hook_active`). Local hosts and SSH hosts (orcad) mint it in `attachStatusTiming`; the WSL relay mints it at admission and sends it on its envelope. The field is persisted, forwarded over IPC and the paired-server session projection, and the renderer's done identity is `turnEndedAt ?? stateStartedAt` (after the existing `turnCompletedAt` precedence). `stateStartedAt` and every other clock are unchanged. Paired servers that predate the stamp advertise no `agent-status.turn-ended-at.v1` capability; their panes keep the terminal-title completion fallback exactly as before. * test(notifications): type the old-server pane setup in the completion flow test * fix(notifications): a blocking Stop's final banner reaches the user; /compact and quitting an idle agent stay quiet Real-agent QA on the hook-only completion design found three gaps: - A Stop re-entered after a blocking Stop hook could be dropped twice over: inside 1 s of the first banner by the replay guard (a new host turn-end stamp is now a real end), and by main's 5 s per-workspace desktop cooldown. A banner with the same notification id and changed text now replaces the one already shown instead of being dropped; every other cooldown rule is unchanged. - On a mounted pane, a hook-owned idle edge (Codex /compact) no longer claimed a completion, so the burst's BEL went out as a Bell banner. The deferred idle edge now absorbs that BEL like a claimed completion did. - Quitting an idle agent after a title spin raised a generic process-exit banner. On a pane whose hook lane owns turn ends and whose last turn is done, an exit ends no turn; a crash mid-turn still gets its banner. * test(runtime): the projection reference keys on the host turn-end stamp too * test(relay): restore the Copilot retry test while CI isolates a runner error * test: key the hot-path projection reference on the turn-end stamp; relay retry restates the end * fix(notifications): host turn identity per agent session; keep later blocking-Stop banners past main's cooldown - The execution host (local main and the WSL relay, one shared reducer) now tracks which turn an agent session is in: Codex by its own turn id, Command Code by its transcript prompt position, every other agent by its prompt-opening hook. A turn end mints a new end when its turn differs from the last ended one, so a Codex turn woken by agent mail and a text-only Command Code turn get their banner; duplicates, replays, tool reopens and result retries restate it. The record is saved with the status row (validated on restore) and never published. - Main's desktop and mobile notification cooldowns are back to main's code exactly. - A later end in the same done segment (a Stop re-entered after a blocking Stop hook) that lands inside main's cooldown is held, latest only, and sent once after that cooldown through main's unchanged gates; the renderer learns main's answer to anchor it. - One pane check gates every changed pane behavior: the pane's bound PTY runs on a host that stamps turn ends and its agent's continuation signal is verified (Claude, Codex). Every other pane, and an unbound PTY, keeps main's behavior. - A hook-owned idle edge absorbs its burst's BEL for main's full 1.5 s; exits and replacements ask the owner check with the exiting agent; a title spin on a hook-owned pane opens no turn. * test(notifications): run the whole-pane harness through main's shared desktop cooldown, not main's service module * fix(notifications): decide hook ownership from how the host identified each turn end The execution host now labels every classified turn end with one optional disposition beside its end stamp, by the same rule for every agent: identified (an unused prompt opening, a new native turn id, or a verified re-entered Stop) mints a new end; a same-turn repeat is a proven duplicate only when the agent supplies both a turn id and a verified continuation flag; anything else is ambiguous and never mints. Replays and retries restate the decided answer, and local and relay hosts carry an end only within a done segment. The renderer replaces the Claude/Codex pane gate with one evidence decision: identified ends are hook-owned, ambiguous ends keep the title lane's role, and rows without a disposition (older hosts, unclassified agents) keep the previous behavior exactly. A title working edge on a hook-owned pane keeps its turn bookkeeping and only the hook receipt; an owned idle edge settles after the existing max wait, or announces through the title path if the end turned out ambiguous. Idle and working title edges resolve the same owner (the title's agent, else the foreground one). The held later banner for identified continuations is unchanged. * test(notifications): give the whole-pane harness's host event its local connection id * fix(notifications): a settled cancellation ends the open turn, so its late Stop folds as on main A cancel Orca inferred from Esc or Ctrl+C writes a done row with no turn-end event. The host reducer now completes the current turn key on that settled cancellation, so the same turn's late Stop is a repeat (ambiguous, no new stamp) instead of a second identified end that requested a second banner. * fix(notifications): a settled cancellation ends the open turn on every lane, so a relay's late cancel end restates none The desktop restates a WSL relay's end as decided. The relay never learns a cancel the desktop inferred for a non-Claude agent, so Grok's own late stop_cancelled reached the desktop as an identified end and gave a second banner. One predicate now decides both lanes: after a settled cancellation that carried no end, a restated end keeps the shown one, and a child event carrying the cancellation also ends the open turn. The relay's own cancel row is the main agent's, never a child's. * fix(notifications): a relayed done segment shown without its own end keeps showing none The restatement fold tested the previous row's cancel verdict, so the row it left behind (a late plain stop) no longer matched and the relay's next restatement brought its end back. Fold on the row's turn-end fact instead, which the folded row satisfies again.
This commit is contained in:
@@ -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<string, unknown>, path = '/hook/claude'): Promise<void> {
|
||||
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<string, unknown>) => 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' })
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<typeof createAgentCompletionProcessMonitor>
|
||||
let pendingTitle!: ReturnType<typeof createPendingTitleController>
|
||||
let notification!: ReturnType<typeof createAgentCompletionNotificationController>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+18
-8
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AgentStatusEntry, 'state' | 'stateStartedAt'> | undefined,
|
||||
storedAgentStatus: Pick<AgentStatusEntry, 'state' | 'stateStartedAt' | 'turnEndedAt'> | 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
|
||||
}
|
||||
|
||||
@@ -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<typeof setTimeout> | 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown>[] = []
|
||||
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<string, number>()
|
||||
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<void> {
|
||||
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<typeof dispatchTerminalNotification>[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))
|
||||
})
|
||||
})
|
||||
@@ -34,6 +34,7 @@ type MockStoreState = {
|
||||
setCacheTimerStartedAt: ReturnType<typeof vi.fn>
|
||||
observeTerminalGitHubPullRequestLink: ReturnType<typeof vi.fn>
|
||||
agentStatusByPaneKey: Record<string, { state: string; prompt: string; agentType?: string }>
|
||||
paneForegroundAgentByPaneKey: Record<string, { agent: string | null }>
|
||||
}
|
||||
|
||||
const dispatchTerminalNotification = vi.fn()
|
||||
@@ -81,7 +82,8 @@ function createMockStoreState(): MockStoreState {
|
||||
markTerminalBell: vi.fn(),
|
||||
setCacheTimerStartedAt: vi.fn(),
|
||||
observeTerminalGitHubPullRequestLink: vi.fn(),
|
||||
agentStatusByPaneKey: {}
|
||||
agentStatusByPaneKey: {},
|
||||
paneForegroundAgentByPaneKey: {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof setTimeout> | null = null
|
||||
let agentTaskCompleteTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let ownedIdleTimer: ReturnType<typeof setTimeout> | 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 => {
|
||||
|
||||
+16
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<RuntimeTerminalProcessInspection> => ({
|
||||
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()
|
||||
|
||||
@@ -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<typeof setTimeout> | null
|
||||
}
|
||||
|
||||
// Why: these answers mean a later request would be refused too, so a held revision is dropped.
|
||||
const TERMINAL_REFUSALS: ReadonlySet<NotificationDispatchResult['reason']> = new Set([
|
||||
'disabled',
|
||||
'source-disabled',
|
||||
'host-muted',
|
||||
'not-supported',
|
||||
'blocked-by-system',
|
||||
'invalid-request'
|
||||
])
|
||||
|
||||
const requestsByPaneKey = new Map<string, PaneRequests>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<HostTurnEndFacts, 'turnEndDisposition'> | undefined
|
||||
): HookTurnEndEvidence {
|
||||
const disposition = row?.turnEndDisposition
|
||||
return disposition && ptyHostStampsTurnEnds(ptyId) ? disposition : 'legacy'
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<AgentStatusIpcPayload, 'isReplay' | 'stateStartedAt' | 'turnCompletedAt'>,
|
||||
data: Pick<
|
||||
AgentStatusIpcPayload,
|
||||
'isReplay' | 'stateStartedAt' | 'turnCompletedAt' | 'turnEndedAt'
|
||||
>,
|
||||
state: string,
|
||||
existing: Pick<AppState['agentStatusByPaneKey'][string], 'state' | 'stateStartedAt'> | undefined
|
||||
existing:
|
||||
| Pick<AppState['agentStatusByPaneKey'][string], 'state' | 'stateStartedAt' | 'turnEndedAt'>
|
||||
| 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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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<typeof pickParsedAgentStatusPayload> & {
|
||||
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 } : {})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -329,6 +329,7 @@ export function normalizeHookPayload(
|
||||
: {}),
|
||||
...(providerSession ? { providerSession } : {}),
|
||||
...(providerSessionOnly ? { providerSessionOnly: true } : {}),
|
||||
...dispatched.hostTurnSignals,
|
||||
payload: transportPayload
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<AgentHookEventPayload, 'stopHookActive' | 'nativeTurnKey'>
|
||||
}
|
||||
|
||||
/** 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
promptInteractionKey: string | undefined
|
||||
): Pick<AgentHookEventPayload, 'stopHookActive' | 'nativeTurnKey'> {
|
||||
// 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<AgentHookEventPayload['source']> }
|
||||
): { 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
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<string> 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<string> = new Set<string>(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)
|
||||
}
|
||||
|
||||
@@ -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<string> = new Set(['claude', 'codex'])
|
||||
|
||||
export function agentReportsStopHookContinuation(agent: string | null | undefined): boolean {
|
||||
return agent !== null && agent !== undefined && STOP_HOOK_CONTINUATION_AGENTS.has(agent)
|
||||
}
|
||||
@@ -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<string, number>, now: number): void {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -77,6 +77,7 @@ export function applyRelayClaudeInterrupt(
|
||||
{
|
||||
...row,
|
||||
hookEventName: undefined,
|
||||
toolAgentId: undefined,
|
||||
hasExplicitPrompt: undefined,
|
||||
hostEvidenceObservedAt: Date.now(),
|
||||
claudeRunningNonAgentTask: claudeRowHasUnlistedLiveWork(host.state, row.paneKey),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>) =>
|
||||
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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user