From 317e2ea1fee935a1bdb8dff301a0a99db65c8f56 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 23 Jul 2026 23:44:39 -0700 Subject: [PATCH 1/8] feat(protocol): add terminal.idle wire schema for truly-idle alerting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pinned contract shared with the Rust server port: { terminalId, at (server epoch ms), reason: 'grace' | 'queue-empty' }. πŸ€– Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- shared/ws-protocol.ts | 20 ++++++++++++++++++++ test/server/ws-protocol.test.ts | 8 ++++++++ 2 files changed, 28 insertions(+) diff --git a/shared/ws-protocol.ts b/shared/ws-protocol.ts index a1bdc5517..bcd81cfab 100644 --- a/shared/ws-protocol.ts +++ b/shared/ws-protocol.ts @@ -194,6 +194,24 @@ export const TerminalTurnCompleteSchema = z.object({ completionSeq: z.number().int().positive(), }) +/** + * Truly-idle edge for terminal-mode CLI panes (claude/codex/opencode/amplifier). + * Emitted once per busy -> truly-idle transition, after a grace window with no + * new session-file activity AND no detectable queued user prompt. Never emitted + * after crash/interrupt/exit; subagent completions inside a running turn never + * produce it. This is the ONLY edge the client rings/shades on for terminal CLI + * panes ('terminal.turn.complete' stays informational for them). + * + * Pinned wire contract shared with the Rust server port - do not change + * unilaterally: { terminalId, at (server epoch ms), reason: 'grace' | 'queue-empty' }. + */ +export const TerminalIdleSchema = z.object({ + type: z.literal('terminal.idle'), + terminalId: z.string().min(1), + at: z.number().int().nonnegative(), + reason: z.enum(['grace', 'queue-empty']), +}) + // ────────────────────────────────────────────────────────────── // SDK content block schemas (from Claude Code NDJSON) // ────────────────────────────────────────────────────────────── @@ -759,6 +777,7 @@ export type AmplifierActivityListResponseMessage = z.infer export type TerminalTurnCompleteMessage = z.infer +export type TerminalIdleMessage = z.infer // -- Sessions -- @@ -990,6 +1009,7 @@ export type ServerMessage = | AmplifierActivityListResponseMessage | AmplifierActivityUpdatedMessage | TerminalTurnCompleteMessage + | TerminalIdleMessage | SessionsChangedMessage | SettingsUpdatedMessage | UiCommandMessage diff --git a/test/server/ws-protocol.test.ts b/test/server/ws-protocol.test.ts index 8df1d160e..4f18c32d9 100644 --- a/test/server/ws-protocol.test.ts +++ b/test/server/ws-protocol.test.ts @@ -13,6 +13,7 @@ import { TerminalAttachSchema, TerminalInputSchema, TerminalResizeSchema, + TerminalIdleSchema, TerminalTurnCompleteSchema, } from '../../shared/ws-protocol.js' import { @@ -2021,4 +2022,11 @@ describe('claude activity protocol', () => { expect(TerminalTurnCompleteSchema.safeParse({ type: 'terminal.turn.complete', terminalId: 't1', provider: 'claude', at: 1, completionSeq: 1 }).success).toBe(true) expect(TerminalTurnCompleteSchema.safeParse({ type: 'terminal.turn.complete', terminalId: 't1', provider: 'opencode', sessionId: 's1', at: 1, completionSeq: 1 }).success).toBe(true) }) + it('terminal.idle carries terminalId, server-epoch at, and a grace/queue-empty reason', () => { + expect(TerminalIdleSchema.safeParse({ type: 'terminal.idle', terminalId: 't1', at: 1, reason: 'grace' }).success).toBe(true) + expect(TerminalIdleSchema.safeParse({ type: 'terminal.idle', terminalId: 't1', at: 1, reason: 'queue-empty' }).success).toBe(true) + expect(TerminalIdleSchema.safeParse({ type: 'terminal.idle', terminalId: 't1', at: 1, reason: 'exit' }).success).toBe(false) + expect(TerminalIdleSchema.safeParse({ type: 'terminal.idle', terminalId: 't1', reason: 'grace' }).success).toBe(false) + expect(TerminalIdleSchema.safeParse({ type: 'terminal.idle', terminalId: '', at: 1, reason: 'grace' }).success).toBe(false) + }) }) From 7fdb0e452989607f11c68e614e6916725ad61394 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 23 Jul 2026 23:47:46 -0700 Subject: [PATCH 2/8] =?UTF-8?q?feat(server):=20TrulyIdleEmitter=20?= =?UTF-8?q?=E2=80=94=20busy=E2=86=92truly-idle=20state=20machine=20with=20?= =?UTF-8?q?one-shot=20grace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provider-agnostic emitter fed by each activity tracker's 'changed' + 'turn.complete' streams. Turn boundary landing idle arms a one-shot 2s grace timer (cancelled by any busy flip); a boundary while still busy is a queued turn (hold the bell, reason queue-empty on drain); deadman / signal-loss idle flips and PTY exit/crash removals never emit. Zero polling: one-shot unref'd timers only. πŸ€– Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- server/coding-cli/truly-idle-emitter.ts | 202 +++++++++++++++++ .../coding-cli/truly-idle-emitter.test.ts | 207 ++++++++++++++++++ 2 files changed, 409 insertions(+) create mode 100644 server/coding-cli/truly-idle-emitter.ts create mode 100644 test/unit/server/coding-cli/truly-idle-emitter.test.ts diff --git a/server/coding-cli/truly-idle-emitter.ts b/server/coding-cli/truly-idle-emitter.ts new file mode 100644 index 000000000..e448f7fbc --- /dev/null +++ b/server/coding-cli/truly-idle-emitter.ts @@ -0,0 +1,202 @@ +import { EventEmitter } from 'events' + +/** + * Truly-idle grace window (pinned wire contract, shared with the Rust server + * port β€” do not change unilaterally): after a turn boundary the terminal must + * stay quiet (no new session-file activity, no queued user prompt) this long + * before a single `terminal.idle` edge is emitted. + */ +export const TERMINAL_IDLE_GRACE_MS = 2_000 + +export type TrulyIdleReason = 'grace' | 'queue-empty' + +export type TrulyIdleEvent = { + terminalId: string + /** Server epoch ms at emit time. */ + at: number + reason: TrulyIdleReason +} + +export type TrulyIdleActivityUpsert = { + terminalId: string + /** Provider tracker phase. 'busy' and 'pending' count as busy; anything else is not. */ + phase: string +} + +export type TrulyIdleActivityChange = { + upsert?: TrulyIdleActivityUpsert[] + remove?: string[] +} + +type TerminalIdleState = { + busy: boolean + /** True while the tracker phase is 'pending' (codex submit gate). */ + pending: boolean + /** Queue evidence observed since the last emit (queued turn / re-armed submit). */ + sawQueueEvidence: boolean + graceTimer?: ReturnType +} + +function isBusyPhase(phase: string): boolean { + return phase === 'busy' || phase === 'pending' +} + +/** + * Provider-agnostic truly-idle state machine, keyed by terminalId. + * + * Consumes the two event streams every activity tracker already emits β€” + * 'changed' (phase upserts / removals) and 'turn.complete' (turn boundaries) β€” + * and emits a single 'idle' edge per busyβ†’truly-idle transition: + * + * - A turn boundary while the tracker still reports busy is a queued turn + * (claude inFlight ledger keeps phase busy until the queue drains): record + * queue evidence, never arm. + * - A turn boundary landing idle arms a ONE-SHOT grace timer. Any busy flip + * inside the window (new prompt:submit record, task_started, PTY submit + * provisional busy) cancels it β€” no per-terminal intervals, no polling. + * - A codex busyβ†’pending re-arm (queued submit consumed at turn clear) counts + * as queue evidence; codex emits its completion only when the queue drained. + * - Deadman/signal-loss idle flips arrive WITHOUT a turn boundary and never + * arm; PTY exit / crash removals cancel any armed timer and never emit. + * - OpenCode's genuine turn end arrives as activityRemove followed by + * turnComplete: the removal clears state, the boundary then arms grace-only. + * + * Emits 'idle' with TrulyIdleEvent payloads. + */ +export class TrulyIdleEmitter extends EventEmitter { + private readonly states = new Map() + private readonly graceMs: number + private readonly now: () => number + private readonly setTimeoutFn: typeof setTimeout + private readonly clearTimeoutFn: typeof clearTimeout + + constructor(input: { + graceMs?: number + now?: () => number + setTimeoutFn?: typeof setTimeout + clearTimeoutFn?: typeof clearTimeout + } = {}) { + super() + this.graceMs = input.graceMs ?? TERMINAL_IDLE_GRACE_MS + this.now = input.now ?? (() => Date.now()) + this.setTimeoutFn = input.setTimeoutFn ?? setTimeout + this.clearTimeoutFn = input.clearTimeoutFn ?? clearTimeout + } + + noteActivityChanged(change: TrulyIdleActivityChange): void { + for (const record of change.upsert ?? []) { + const state = this.getOrCreate(record.terminalId) + const nextBusy = isBusyPhase(record.phase) + const nextPending = record.phase === 'pending' + if (nextBusy) { + // Direct busyβ†’pending is a queued submit consumed at a turn clear + // (codex re-arm) β€” the busy chain continues with a queued prompt. + if (state.busy && !state.pending && nextPending) { + state.sawQueueEvidence = true + } + this.cancelGrace(state) + } + state.busy = nextBusy + state.pending = nextPending + } + for (const terminalId of change.remove ?? []) { + const state = this.states.get(terminalId) + if (!state) continue + // Exit/crash (or an opencode idle removal): never emit from here β€” only + // a subsequent turn boundary may re-arm. + this.cancelGrace(state) + this.states.delete(terminalId) + } + } + + noteTurnComplete(event: { terminalId: string; at: number }): void { + const state = this.getOrCreate(event.terminalId) + if (state.busy) { + // Queued turn still pending (claude inFlight > 0): hold the bell until + // the queue drains to a boundary that lands idle. + state.sawQueueEvidence = true + return + } + this.armGrace(event.terminalId, state) + } + + /** Clear every armed grace timer (wiring dispose). */ + dispose(): void { + for (const state of this.states.values()) { + this.cancelGrace(state) + } + this.states.clear() + } + + private getOrCreate(terminalId: string): TerminalIdleState { + let state = this.states.get(terminalId) + if (!state) { + state = { busy: false, pending: false, sawQueueEvidence: false } + this.states.set(terminalId, state) + } + return state + } + + private armGrace(terminalId: string, state: TerminalIdleState): void { + this.cancelGrace(state) + const timer = this.setTimeoutFn(() => { + this.handleGraceExpiry(terminalId) + }, this.graceMs) + // Never keep the event loop alive solely for a grace timer. + ;(timer as unknown as { unref?: () => void }).unref?.() + state.graceTimer = timer + } + + private cancelGrace(state: TerminalIdleState): void { + if (state.graceTimer !== undefined) { + this.clearTimeoutFn(state.graceTimer) + state.graceTimer = undefined + } + } + + private handleGraceExpiry(terminalId: string): void { + const state = this.states.get(terminalId) + if (!state) return + state.graceTimer = undefined + if (state.busy) return + const reason: TrulyIdleReason = state.sawQueueEvidence ? 'queue-empty' : 'grace' + state.sawQueueEvidence = false + this.emit('idle', { + terminalId, + at: this.now(), + reason, + } satisfies TrulyIdleEvent) + } +} + +type TrulyIdleTrackerLike = { + on(event: string, handler: (...args: any[]) => void): unknown + off(event: string, handler: (...args: any[]) => void): unknown +} + +/** + * Wire a provider activity tracker's 'changed' + 'turn.complete' streams into + * a TrulyIdleEmitter. Returns a dispose that detaches the listeners and clears + * the emitter's timers. + */ +export function wireTrulyIdleEmitter(input: { + tracker: TrulyIdleTrackerLike + emitter: TrulyIdleEmitter +}): { dispose(): void } { + const { tracker, emitter } = input + const onChanged = (change: TrulyIdleActivityChange) => { + emitter.noteActivityChanged(change) + } + const onTurnComplete = (event: { terminalId: string; at: number }) => { + emitter.noteTurnComplete(event) + } + tracker.on('changed', onChanged) + tracker.on('turn.complete', onTurnComplete) + return { + dispose(): void { + tracker.off('changed', onChanged) + tracker.off('turn.complete', onTurnComplete) + emitter.dispose() + }, + } +} diff --git a/test/unit/server/coding-cli/truly-idle-emitter.test.ts b/test/unit/server/coding-cli/truly-idle-emitter.test.ts new file mode 100644 index 000000000..f761959e8 --- /dev/null +++ b/test/unit/server/coding-cli/truly-idle-emitter.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { + TERMINAL_IDLE_GRACE_MS, + TrulyIdleEmitter, + type TrulyIdleEvent, +} from '../../../../server/coding-cli/truly-idle-emitter.js' + +describe('TrulyIdleEmitter', () => { + let emitter: TrulyIdleEmitter + let events: TrulyIdleEvent[] + + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-23T12:00:00Z')) + emitter = new TrulyIdleEmitter() + events = [] + emitter.on('idle', (event: TrulyIdleEvent) => events.push(event)) + }) + + afterEach(() => { + emitter.dispose() + vi.useRealTimers() + }) + + it('pins the shared grace default at 2000ms', () => { + expect(TERMINAL_IDLE_GRACE_MS).toBe(2000) + }) + + it('emits exactly one terminal.idle (reason grace) after a quiet grace window following a turn end', () => { + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'busy' }], remove: [] }) + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'idle' }], remove: [] }) + emitter.noteTurnComplete({ terminalId: 't1', at: Date.now() }) + + vi.advanceTimersByTime(TERMINAL_IDLE_GRACE_MS - 1) + expect(events).toHaveLength(0) + + vi.advanceTimersByTime(1) + expect(events).toHaveLength(1) + expect(events[0]).toEqual({ + terminalId: 't1', + at: Date.now(), + reason: 'grace', + }) + + // One-shot: nothing further without a new turn boundary. + vi.advanceTimersByTime(TERMINAL_IDLE_GRACE_MS * 5) + expect(events).toHaveLength(1) + }) + + it('suppresses the bell between back-to-back turns and emits once at the very end', () => { + // Turn 1 ends... + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'busy' }], remove: [] }) + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'idle' }], remove: [] }) + emitter.noteTurnComplete({ terminalId: 't1', at: Date.now() }) + + // ...but a new turn starts inside the grace window (new session-file activity). + vi.advanceTimersByTime(500) + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'busy' }], remove: [] }) + vi.advanceTimersByTime(TERMINAL_IDLE_GRACE_MS * 3) + expect(events).toHaveLength(0) + + // Turn 2 ends and stays quiet. + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'idle' }], remove: [] }) + emitter.noteTurnComplete({ terminalId: 't1', at: Date.now() }) + vi.advanceTimersByTime(TERMINAL_IDLE_GRACE_MS) + expect(events).toHaveLength(1) + expect(events[0].reason).toBe('grace') + }) + + it('holds the bell while a queued prompt keeps the terminal busy, then emits queue-empty after the queue drains', () => { + // Claude-style: turn 1 completes while phase stays busy (inFlight > 0 -> queued turn pending). + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'busy' }], remove: [] }) + emitter.noteTurnComplete({ terminalId: 't1', at: Date.now() }) + vi.advanceTimersByTime(TERMINAL_IDLE_GRACE_MS * 3) + expect(events).toHaveLength(0) + + // Final queued turn drains: phase flips idle, then the completion lands. + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'idle' }], remove: [] }) + emitter.noteTurnComplete({ terminalId: 't1', at: Date.now() }) + vi.advanceTimersByTime(TERMINAL_IDLE_GRACE_MS) + expect(events).toHaveLength(1) + expect(events[0].reason).toBe('queue-empty') + }) + + it('treats a codex busy->pending re-arm as queue evidence', () => { + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'busy' }], remove: [] }) + // Queued submit consumed at turn clear: tracker re-arms to pending (still busy, no completion). + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'pending' }], remove: [] }) + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'busy' }], remove: [] }) + vi.advanceTimersByTime(TERMINAL_IDLE_GRACE_MS * 3) + expect(events).toHaveLength(0) + + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'idle' }], remove: [] }) + emitter.noteTurnComplete({ terminalId: 't1', at: Date.now() }) + vi.advanceTimersByTime(TERMINAL_IDLE_GRACE_MS) + expect(events).toHaveLength(1) + expect(events[0].reason).toBe('queue-empty') + }) + + it('does not count an initial idle->pending submit as queue evidence', () => { + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'pending' }], remove: [] }) + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'busy' }], remove: [] }) + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'idle' }], remove: [] }) + emitter.noteTurnComplete({ terminalId: 't1', at: Date.now() }) + vi.advanceTimersByTime(TERMINAL_IDLE_GRACE_MS) + expect(events).toHaveLength(1) + expect(events[0].reason).toBe('grace') + }) + + it('never emits after a crash/exit (activity remove), even with a grace timer armed', () => { + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'busy' }], remove: [] }) + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'idle' }], remove: [] }) + emitter.noteTurnComplete({ terminalId: 't1', at: Date.now() }) + // PTY exit lands inside the grace window. + emitter.noteActivityChanged({ upsert: [], remove: ['t1'] }) + vi.advanceTimersByTime(TERMINAL_IDLE_GRACE_MS * 3) + expect(events).toHaveLength(0) + }) + + it('never emits on a deadman/signal-loss idle flip (phase idle without a turn boundary)', () => { + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'busy' }], remove: [] }) + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'idle' }], remove: [] }) + vi.advanceTimersByTime(TERMINAL_IDLE_GRACE_MS * 3) + expect(events).toHaveLength(0) + }) + + it('never emits on a codex busy->unknown deadman flip', () => { + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'busy' }], remove: [] }) + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'unknown' }], remove: [] }) + vi.advanceTimersByTime(TERMINAL_IDLE_GRACE_MS * 3) + expect(events).toHaveLength(0) + }) + + it('arms from a turn.complete that follows an opencode-style activity remove (idle = record removed)', () => { + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'busy' }], remove: [] }) + // OpenCode reducer emits activityRemove then turnComplete for a genuine turn end. + emitter.noteActivityChanged({ upsert: [], remove: ['t1'] }) + emitter.noteTurnComplete({ terminalId: 't1', at: Date.now() }) + vi.advanceTimersByTime(TERMINAL_IDLE_GRACE_MS) + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ terminalId: 't1', reason: 'grace' }) + }) + + it('re-arms (single emit) when a second turn.complete lands while the grace timer is armed', () => { + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'idle' }], remove: [] }) + emitter.noteTurnComplete({ terminalId: 't1', at: Date.now() }) + vi.advanceTimersByTime(TERMINAL_IDLE_GRACE_MS - 500) + emitter.noteTurnComplete({ terminalId: 't1', at: Date.now() }) + + // The original deadline passes without an emit (timer was re-armed)... + vi.advanceTimersByTime(500) + expect(events).toHaveLength(0) + + // ...and the re-armed window emits exactly once. + vi.advanceTimersByTime(TERMINAL_IDLE_GRACE_MS - 500) + expect(events).toHaveLength(1) + }) + + it('tracks terminals independently', () => { + emitter.noteActivityChanged({ + upsert: [ + { terminalId: 't1', phase: 'idle' }, + { terminalId: 't2', phase: 'busy' }, + ], + remove: [], + }) + emitter.noteTurnComplete({ terminalId: 't1', at: Date.now() }) + vi.advanceTimersByTime(TERMINAL_IDLE_GRACE_MS) + expect(events).toHaveLength(1) + expect(events[0].terminalId).toBe('t1') + }) + + it('stamps at with the server clock at emit time', () => { + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'idle' }], remove: [] }) + const turnEndAt = Date.now() + emitter.noteTurnComplete({ terminalId: 't1', at: turnEndAt }) + vi.advanceTimersByTime(TERMINAL_IDLE_GRACE_MS) + expect(events[0].at).toBe(turnEndAt + TERMINAL_IDLE_GRACE_MS) + }) + + it('resets queue evidence after each emit', () => { + // Queue evidence in the first busy period... + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'busy' }], remove: [] }) + emitter.noteTurnComplete({ terminalId: 't1', at: Date.now() }) + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'idle' }], remove: [] }) + emitter.noteTurnComplete({ terminalId: 't1', at: Date.now() }) + vi.advanceTimersByTime(TERMINAL_IDLE_GRACE_MS) + expect(events).toHaveLength(1) + expect(events[0].reason).toBe('queue-empty') + + // ...must not leak into the next simple turn. + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'busy' }], remove: [] }) + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'idle' }], remove: [] }) + emitter.noteTurnComplete({ terminalId: 't1', at: Date.now() }) + vi.advanceTimersByTime(TERMINAL_IDLE_GRACE_MS) + expect(events).toHaveLength(2) + expect(events[1].reason).toBe('grace') + }) + + it('dispose cancels every armed timer', () => { + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'idle' }], remove: [] }) + emitter.noteTurnComplete({ terminalId: 't1', at: Date.now() }) + emitter.dispose() + vi.advanceTimersByTime(TERMINAL_IDLE_GRACE_MS * 3) + expect(events).toHaveLength(0) + }) +}) From 217d23c090cad4831b20add958360255d5fab021 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 23 Jul 2026 23:50:57 -0700 Subject: [PATCH 3/8] feat(server): broadcast terminal.idle from all four terminal-CLI trackers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wireTrulyIdleEmitter on claude/codex/amplifier/opencode trackers -> wsHandler.broadcastTerminalIdle. Wirings disposed on shutdown. πŸ€– Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- server/index.ts | 14 +++ server/ws-handler.ts | 16 +++ test/server/ws-terminal-idle.test.ts | 175 +++++++++++++++++++++++++++ 3 files changed, 205 insertions(+) create mode 100644 test/server/ws-terminal-idle.test.ts diff --git a/server/index.ts b/server/index.ts index 779c60a01..d58b99925 100644 --- a/server/index.ts +++ b/server/index.ts @@ -24,6 +24,7 @@ import { CodingCliSessionManager } from './coding-cli/session-manager.js' import { wireCodexActivityTracker } from './coding-cli/codex-activity-wiring.js' import { wireClaudeActivityTracker } from './coding-cli/claude-activity-wiring.js' import { wireAmplifierActivityTracker } from './coding-cli/amplifier-activity-wiring.js' +import { TrulyIdleEmitter, wireTrulyIdleEmitter } from './coding-cli/truly-idle-emitter.js' import { createAmplifierActivityIntegration } from './coding-cli/amplifier-activity-integration.js' import { AmplifierSessionLocator } from './coding-cli/amplifier-session-locator.js' import { AmplifierSessionController } from './coding-cli/amplifier-session-controller.js' @@ -539,6 +540,18 @@ async function main() { const sessionsSync = new SessionsSyncService(wsHandler) const associationCoordinator = new SessionAssociationCoordinator(registry, ASSOCIATION_MAX_AGE_MS) + // Truly-idle alerting (terminal.idle): one emitter per provider tracker, + // fed by the tracker's 'changed' + 'turn.complete' streams. One-shot grace + // timers only β€” no polling, no per-pane intervals. + const trulyIdleWirings = [codexActivity.tracker, claudeActivity.tracker, amplifierActivity.tracker, opencodeActivity.tracker] + .map((tracker) => { + const emitter = new TrulyIdleEmitter() + emitter.on('idle', (event) => { + wsHandler.broadcastTerminalIdle(event) + }) + return wireTrulyIdleEmitter({ tracker, emitter }) + }) + codexActivity.tracker.on('changed', (payload) => { wsHandler.broadcastCodexActivityUpdated(payload) }) @@ -1227,6 +1240,7 @@ async function main() { await extWatcher?.close() // 9b. Stop Codex activity tracker listeners and sweep timer + for (const wiring of trulyIdleWirings) wiring.dispose() codexActivity.dispose() claudeActivity.dispose() amplifierActivity.dispose() diff --git a/server/ws-handler.ts b/server/ws-handler.ts index f65bfefc7..fb4fa3ac4 100644 --- a/server/ws-handler.ts +++ b/server/ws-handler.ts @@ -27,6 +27,7 @@ import type { OpencodeActivityRecord, TerminalTurnCompletionSnapshot, TerminalTurnCompleteMessage, + TerminalIdleMessage, } from '../shared/ws-protocol.js' import type { ExtensionManager } from './extension-manager.js' import { allocateLocalhostPort } from './local-port.js' @@ -71,6 +72,7 @@ import { OpencodeActivityListSchema, OpencodeActivityUpdatedSchema, TerminalTurnCompleteSchema, + TerminalIdleSchema, HelloSchema, PingSchema, ClientDiagnosticSchema, @@ -3823,6 +3825,20 @@ export class WsHandler { this.broadcastAuthenticated(parsed.data) } + broadcastTerminalIdle(msg: Omit): void { + const parsed = TerminalIdleSchema.safeParse({ + type: 'terminal.idle', + ...msg, + }) + + if (!parsed.success) { + log.warn({ issues: parsed.error.issues }, 'Invalid terminal.idle payload') + return + } + + this.broadcastAuthenticated(parsed.data) + } + /** * Prepare for hot rebind: close all client connections and set the closed * flag so the patched server.close() β†’ this.close() is a no-op. diff --git a/test/server/ws-terminal-idle.test.ts b/test/server/ws-terminal-idle.test.ts new file mode 100644 index 000000000..2a60b3f25 --- /dev/null +++ b/test/server/ws-terminal-idle.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest' +import http from 'http' +import WebSocket from 'ws' +import { WS_PROTOCOL_VERSION } from '../../shared/ws-protocol' +import { ClaudeActivityTracker } from '../../server/coding-cli/claude-activity-tracker.js' +import { + TrulyIdleEmitter, + wireTrulyIdleEmitter, + type TrulyIdleEvent, +} from '../../server/coding-cli/truly-idle-emitter.js' + +vi.mock('../../server/config-store', () => ({ + configStore: { + snapshot: vi.fn().mockResolvedValue({ + version: 1, + settings: {}, + sessionOverrides: {}, + terminalOverrides: {}, + projectColors: {}, + }), + }, +})) + +const GRACE_MS = 100 + +function listen(server: http.Server): Promise { + return new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + if (!address || typeof address === 'string') { + reject(new Error('Failed to bind test server')) + return + } + resolve(address.port) + }) + }) +} + +function waitForMessage(ws: WebSocket, predicate: (msg: any) => boolean, timeoutMs = 3000): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + ws.off('message', onMessage) + reject(new Error('Timed out waiting for websocket message')) + }, timeoutMs) + const onMessage = (raw: WebSocket.Data) => { + const msg = JSON.parse(raw.toString()) + if (!predicate(msg)) return + clearTimeout(timeout) + ws.off('message', onMessage) + resolve(msg) + } + ws.on('message', onMessage) + }) +} + +function expectNoMatchingMessage(ws: WebSocket, predicate: (msg: any) => boolean, timeoutMs = 400): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + ws.off('message', onMessage) + resolve() + }, timeoutMs) + const onMessage = (raw: WebSocket.Data) => { + const msg = JSON.parse(raw.toString()) + if (!predicate(msg)) return + clearTimeout(timeout) + ws.off('message', onMessage) + reject(new Error(`Unexpected websocket message: ${JSON.stringify(msg)}`)) + } + ws.on('message', onMessage) + }) +} + +class FakeRegistry { + list() { return [] } + get() { return null } + create() { throw new Error('not used') } + attach() { return null } + finishAttachSnapshot() {} + detach() { return false } + input() { return false } + resize() { return false } + kill() { return false } + findRunningClaudeTerminalBySession() { return undefined } +} + +describe('ws terminal.idle (server-authoritative truly-idle edge)', () => { + let server: http.Server + let port: number + let wsHandler: any + + beforeAll(async () => { + process.env.NODE_ENV = 'test' + process.env.AUTH_TOKEN = 'terminal-idle-token' + const { WsHandler } = await import('../../server/ws-handler') + server = http.createServer((_req, res) => { res.statusCode = 404; res.end() }) + wsHandler = new WsHandler(server, new FakeRegistry() as any, {}) + port = await listen(server) + }) + + afterAll(async () => { + wsHandler?.close?.() + await new Promise((resolve) => server.close(() => resolve())) + }) + + async function connectClient(): Promise { + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) + await new Promise((resolve) => ws.on('open', () => resolve())) + ws.send(JSON.stringify({ type: 'hello', token: 'terminal-idle-token', protocolVersion: WS_PROTOCOL_VERSION })) + await waitForMessage(ws, (msg) => msg.type === 'ready') + return ws + } + + it('broadcasts one terminal.idle after a quiet grace window, wired exactly like server/index.ts', async () => { + const tracker = new ClaudeActivityTracker() + const trulyIdle = new TrulyIdleEmitter({ graceMs: GRACE_MS }) + const wiring = wireTrulyIdleEmitter({ tracker, emitter: trulyIdle }) + const onIdle = (event: TrulyIdleEvent) => { + wsHandler.broadcastTerminalIdle(event) + } + trulyIdle.on('idle', onIdle) + + const client = await connectClient() + const idles: any[] = [] + client.on('message', (raw) => { + const msg = JSON.parse(raw.toString()) + if (msg.type === 'terminal.idle') idles.push(msg) + }) + + const before = Date.now() + tracker.trackTerminal({ terminalId: 't1', at: before }) + tracker.noteInput({ terminalId: 't1', data: '\r', at: before }) // submit -> busy + tracker.noteOutput({ terminalId: 't1', data: '\x07', at: before + 10 }) // Stop BEL -> idle + turn.complete + + const idle = await waitForMessage(client, (msg) => msg.type === 'terminal.idle' && msg.terminalId === 't1') + expect(idle).toEqual({ + type: 'terminal.idle', + terminalId: 't1', + at: expect.any(Number), + reason: 'grace', + }) + expect(idle.at).toBeGreaterThanOrEqual(before + GRACE_MS) + expect(idles).toHaveLength(1) + + trulyIdle.off('idle', onIdle) + wiring.dispose() + client.close() + }) + + it('does not broadcast terminal.idle when the terminal exits inside the grace window', async () => { + const tracker = new ClaudeActivityTracker() + const trulyIdle = new TrulyIdleEmitter({ graceMs: GRACE_MS }) + const wiring = wireTrulyIdleEmitter({ tracker, emitter: trulyIdle }) + const onIdle = (event: TrulyIdleEvent) => { + wsHandler.broadcastTerminalIdle(event) + } + trulyIdle.on('idle', onIdle) + + const client = await connectClient() + + const at = Date.now() + tracker.trackTerminal({ terminalId: 't2', at }) + tracker.noteInput({ terminalId: 't2', data: '\r', at }) + tracker.noteOutput({ terminalId: 't2', data: '\x07', at: at + 10 }) + tracker.noteExit({ terminalId: 't2' }) // crash/exit inside the grace window + + await expect( + expectNoMatchingMessage(client, (msg) => msg.type === 'terminal.idle' && msg.terminalId === 't2'), + ).resolves.toBeUndefined() + + trulyIdle.off('idle', onIdle) + wiring.dispose() + client.close() + }) +}) From d9b02aa91d6268964d4927e5c362e466575d0c30 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 23 Jul 2026 23:55:56 -0700 Subject: [PATCH 4/8] feat(client): recordTerminalIdle + applyServerIdle for the terminal.idle edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dedicated at-monotonic dedupe namespace (lastIdleAtByTerminalId) so idle edges can never poison (or be poisoned by) turn-complete baselines; reset alongside the completion baselines on a real server restart. πŸ€– Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- src/store/turnCompletionSlice.ts | 32 +++++++++++++ src/store/turnCompletionThunks.ts | 31 ++++++++++++- .../client/store/turnCompletionSlice.test.ts | 45 +++++++++++++++++++ 3 files changed, 107 insertions(+), 1 deletion(-) diff --git a/src/store/turnCompletionSlice.ts b/src/store/turnCompletionSlice.ts index 294034c1e..30e01e123 100644 --- a/src/store/turnCompletionSlice.ts +++ b/src/store/turnCompletionSlice.ts @@ -11,9 +11,19 @@ type TurnCompletePayload = { export type TurnCompleteEvent = TurnCompletePayload & { seq: number } +export type TerminalIdlePayload = { + tabId: string + paneId: string + terminalId: string + at: number + reason: 'grace' | 'queue-empty' +} + export interface TurnCompletionState { seq: number lastAtByTerminalId: Record + /** Truly-idle (terminal.idle) dedupe baseline β€” separate namespace from turn-complete `at`s. */ + lastIdleAtByTerminalId: Record lastAppliedCompletionSeqByTerminalId?: Record pendingEvents: TurnCompleteEvent[] attentionByTab: Record @@ -71,6 +81,7 @@ const persistedTurnCompletion = loadPersistedTurnCompletionState() const initialState: TurnCompletionState = { seq: 0, lastAtByTerminalId: {}, + lastIdleAtByTerminalId: {}, lastAppliedCompletionSeqByTerminalId: persistedTurnCompletion.lastAppliedCompletionSeqByTerminalId, pendingEvents: [], attentionByTab: persistedTurnCompletion.attentionByTab, @@ -103,12 +114,32 @@ const turnCompletionSlice = createSlice({ seq: state.seq, }) }, + // Truly-idle edge (terminal.idle) for terminal CLI panes: the ONLY event that + // rings the bell / shades the tab for claude/codex/opencode/amplifier terminal + // panes. Deduped per terminal by monotonic `at` in its own namespace so it can + // never poison (or be poisoned by) the turn-complete baselines. + recordTerminalIdle(state, action: PayloadAction) { + const { tabId, paneId, terminalId, at } = action.payload + const baselines = state.lastIdleAtByTerminalId ??= {} + const last = baselines[terminalId] + if (last !== undefined && at <= last) return + baselines[terminalId] = at + state.seq += 1 + state.pendingEvents.push({ + tabId, + paneId, + terminalId, + at, + seq: state.seq, + }) + }, // Cleared on a real server restart (not a plain reconnect). The new process has no // buffered events to replay, and its wall clock may be behind a clamp-inflated // pre-restart `at`, so dropping the per-terminal `at` baseline lets the first genuine // post-restart completion through instead of swallowing it as a stale replay. resetCompletionDedupeBaselines(state) { state.lastAtByTerminalId = {} + state.lastIdleAtByTerminalId = {} }, consumeTurnCompleteEvents(state, action: PayloadAction<{ throughSeq: number }>) { const { throughSeq } = action.payload @@ -136,6 +167,7 @@ const turnCompletionSlice = createSlice({ export const { recordTurnComplete, + recordTerminalIdle, resetCompletionDedupeBaselines, consumeTurnCompleteEvents, markTabAttention, diff --git a/src/store/turnCompletionThunks.ts b/src/store/turnCompletionThunks.ts index 02d7a9673..978e30a93 100644 --- a/src/store/turnCompletionThunks.ts +++ b/src/store/turnCompletionThunks.ts @@ -4,7 +4,7 @@ import { collectPaneEntries } from '@/lib/pane-utils' import { resolveFreshAgentSessionKey } from '@/lib/pane-activity' import type { FreshAgentPaneContent, PaneNode } from './paneTypes' import { selectTabPaneByTerminalId } from './selectors/paneTerminalSelectors' -import { recordTurnComplete } from './turnCompletionSlice' +import { recordTerminalIdle, recordTurnComplete } from './turnCompletionSlice' import type { AppDispatch, RootState } from './store' export type ApplyServerCompletionPayload = { @@ -33,6 +33,35 @@ export function applyServerCompletion(payload: ApplyServerCompletionPayload) { } } +export type ApplyServerIdlePayload = { + terminalId: string + at: number + reason: 'grace' | 'queue-empty' +} + +/** + * Server-authoritative truly-idle edge (`terminal.idle`) for terminal CLI panes. + * The ONLY event that rings the bell / shades the tab for claude/codex/opencode/ + * amplifier terminal panes. Resolves the owning tab/pane by terminalId and folds + * the edge into the notification pipeline under the `at`-monotonic idle dedupe + * namespace (reconnect replay cannot re-ring). + */ +export function applyServerIdle(payload: ApplyServerIdlePayload) { + return (dispatch: AppDispatch, getState: () => RootState): void => { + const state = getState() + const location = selectTabPaneByTerminalId(state, payload.terminalId) + if (!location) return + + dispatch(recordTerminalIdle({ + tabId: location.tabId, + paneId: location.paneId, + terminalId: payload.terminalId, + at: payload.at, + reason: payload.reason, + })) + } +} + export type ApplyFreshAgentCompletionPayload = { provider: string sessionId: string diff --git a/test/unit/client/store/turnCompletionSlice.test.ts b/test/unit/client/store/turnCompletionSlice.test.ts index f4d817563..f6ba4c395 100644 --- a/test/unit/client/store/turnCompletionSlice.test.ts +++ b/test/unit/client/store/turnCompletionSlice.test.ts @@ -6,6 +6,7 @@ import reducer, { consumeTurnCompleteEvents, markTabAttention, markPaneAttention, + recordTerminalIdle, recordTurnComplete, resetCompletionDedupeBaselines, type TurnCompletionState, @@ -219,6 +220,7 @@ describe('turnCompletionSlice', () => { const initial: TurnCompletionState = { seq: 0, lastAtByTerminalId: {}, + lastIdleAtByTerminalId: {}, lastAppliedCompletionSeqByTerminalId: {}, pendingEvents: [], attentionByTab: {}, @@ -442,4 +444,47 @@ describe('turnCompletionSlice', () => { expect(store.getState().turnCompletion.lastAppliedCompletionSeqByTerminalId?.['term-missing']).toBeUndefined() }) }) + + describe('recordTerminalIdle (truly-idle edge for terminal CLI panes)', () => { + it('records the idle edge with a pending-event sequence id', () => { + const state = reducer( + undefined, + recordTerminalIdle({ tabId: 'tab-1', paneId: 'pane-1', terminalId: 't1', at: 1_000, reason: 'grace' }) + ) + + expect(state.pendingEvents).toHaveLength(1) + expect(state.pendingEvents[0]).toMatchObject({ + tabId: 'tab-1', + paneId: 'pane-1', + terminalId: 't1', + at: 1_000, + seq: 1, + }) + expect(state.lastIdleAtByTerminalId?.['t1']).toBe(1_000) + }) + + it('dedupes idle edges per terminal by monotonic at (reconnect replay cannot re-ring)', () => { + let state = reducer(undefined, recordTerminalIdle({ tabId: 'tab-1', paneId: 'p1', terminalId: 't1', at: 5_000, reason: 'grace' })) + state = reducer(state, recordTerminalIdle({ tabId: 'tab-1', paneId: 'p1', terminalId: 't1', at: 5_000, reason: 'grace' })) + state = reducer(state, recordTerminalIdle({ tabId: 'tab-1', paneId: 'p1', terminalId: 't1', at: 4_000, reason: 'grace' })) + expect(state.pendingEvents).toHaveLength(1) + + state = reducer(state, recordTerminalIdle({ tabId: 'tab-1', paneId: 'p1', terminalId: 't1', at: 6_000, reason: 'queue-empty' })) + expect(state.pendingEvents).toHaveLength(2) + }) + + it('keeps the idle dedupe namespace separate from turn-complete at baselines', () => { + let state = reducer(undefined, recordTurnComplete({ tabId: 'tab-1', paneId: 'p1', terminalId: 't1', at: 9_000 })) + // A lower idle `at` for the same terminal must still land: different bucket. + state = reducer(state, recordTerminalIdle({ tabId: 'tab-1', paneId: 'p1', terminalId: 't1', at: 1_000, reason: 'grace' })) + expect(state.pendingEvents).toHaveLength(2) + }) + + it('resetCompletionDedupeBaselines clears the idle baseline too (server restart)', () => { + let state = reducer(undefined, recordTerminalIdle({ tabId: 'tab-1', paneId: 'p1', terminalId: 't1', at: 5_000, reason: 'grace' })) + state = reducer(state, resetCompletionDedupeBaselines()) + state = reducer(state, recordTerminalIdle({ tabId: 'tab-1', paneId: 'p1', terminalId: 't1', at: 1_000, reason: 'grace' })) + expect(state.pendingEvents).toHaveLength(2) + }) + }) }) From e097e6c7b5981e4f7d5824badecf1b4fa761b735 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 24 Jul 2026 00:13:01 -0700 Subject: [PATCH 5/8] feat(client): ring/shade only on terminal.idle; turn.complete is informational MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - App.tsx folds terminal.idle in via applyServerIdle; the terminal.turn.complete handler and the latestTurnCompletions replay loops are removed (they minted false bells on subagent finishes and mid-queue turn ends). - All four terminal CLI modes (claude/codex/opencode/amplifier) are now server-authoritative for turn completion: the client BEL path no longer mints completions for them (custom CLI modes keep it). - completionSeq dedupe machinery removed from the slice/persistence β€” the idle edge dedupes by monotonic at; fresh-agent edges keep the at regime. πŸ€– Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- src/App.tsx | 50 ++------ src/lib/terminal-output-side-effects.ts | 5 +- src/store/persistMiddleware.ts | 2 - src/store/turnCompletionSlice.ts | 43 ++----- src/store/turnCompletionThunks.ts | 27 ---- .../turn-complete-notification-flow.test.tsx | 94 ++++++++++++-- .../components/App.ws-bootstrap.test.tsx | 80 ++++-------- .../lib/terminal-output-side-effects.test.ts | 28 ++-- .../store/turnCompletionPersistence.test.ts | 15 +-- .../client/store/turnCompletionSlice.test.ts | 120 +++--------------- 10 files changed, 165 insertions(+), 299 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index f2418e4e8..9388af7a5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -67,7 +67,7 @@ import { setCodexActivitySnapshot, upsertCodexActivity, removeCodexActivity, res import { setClaudeActivitySnapshot, upsertClaudeActivity, removeClaudeActivity, resetClaudeActivity } from '@/store/claudeActivitySlice' import { setAmplifierActivitySnapshot, upsertAmplifierActivity, removeAmplifierActivity, resetAmplifierActivity } from '@/store/amplifierActivitySlice' import { setOpencodeActivitySnapshot, upsertOpencodeActivity, removeOpencodeActivity, resetOpencodeActivity } from '@/store/opencodeActivitySlice' -import { applyServerCompletion } from '@/store/turnCompletionThunks' +import { applyServerIdle } from '@/store/turnCompletionThunks' import { setRegistry, updateServerStatus } from '@/store/extensionsSlice' import { handleFreshAgentMessage } from '@/lib/fresh-agent-ws' import { createLogger } from '@/lib/client-logger' @@ -1063,14 +1063,6 @@ export default function App() { terminals: msg.terminals || [], requestSeq, })) - for (const completion of msg.latestTurnCompletions || []) { - dispatch(applyServerCompletion({ - provider: 'codex', - terminalId: completion.terminalId, - at: completion.at, - completionSeq: completion.completionSeq, - }) as any) - } } if (msg.type === 'codex.activity.updated') { const mutationSeq = ++codexActivityOrderRef.current @@ -1100,14 +1092,6 @@ export default function App() { terminals: msg.terminals || [], requestSeq, })) - for (const completion of msg.latestTurnCompletions || []) { - dispatch(applyServerCompletion({ - provider: 'claude', - terminalId: completion.terminalId, - at: completion.at, - completionSeq: completion.completionSeq, - }) as any) - } } if (msg.type === 'claude.activity.updated') { const mutationSeq = ++claudeActivityOrderRef.current @@ -1137,14 +1121,6 @@ export default function App() { terminals: msg.terminals || [], requestSeq, })) - for (const completion of msg.latestTurnCompletions || []) { - dispatch(applyServerCompletion({ - provider: 'amplifier', - terminalId: completion.terminalId, - at: completion.at, - completionSeq: completion.completionSeq, - }) as any) - } } if (msg.type === 'amplifier.activity.updated') { const mutationSeq = ++amplifierActivityOrderRef.current @@ -1174,14 +1150,6 @@ export default function App() { terminals: msg.terminals || [], requestSeq, })) - for (const completion of msg.latestTurnCompletions || []) { - dispatch(applyServerCompletion({ - provider: 'opencode', - terminalId: completion.terminalId, - at: completion.at, - completionSeq: completion.completionSeq, - }) as any) - } } if (msg.type === 'opencode.activity.updated') { const mutationSeq = ++opencodeActivityOrderRef.current @@ -1201,17 +1169,15 @@ export default function App() { })) } } - if (msg.type === 'terminal.turn.complete') { + // 'terminal.turn.complete' stays informational for terminal CLI panes: + // the client no longer rings/shades on it. The truly-idle edge below is + // the ONLY bell/shade trigger for claude/codex/opencode/amplifier panes. + if (msg.type === 'terminal.idle') { const terminalId = typeof msg.terminalId === 'string' ? msg.terminalId : '' const at = typeof msg.at === 'number' ? msg.at : Date.now() - const completionSeq = typeof msg.completionSeq === 'number' ? msg.completionSeq : undefined - if (terminalId && completionSeq !== undefined) { - dispatch(applyServerCompletion({ - provider: msg.provider, - terminalId, - at, - completionSeq, - }) as any) + const reason = msg.reason === 'queue-empty' ? 'queue-empty' : 'grace' + if (terminalId) { + dispatch(applyServerIdle({ terminalId, at, reason }) as any) } } if (msg.type === 'terminal.exit') { diff --git a/src/lib/terminal-output-side-effects.ts b/src/lib/terminal-output-side-effects.ts index e761304c2..5d89afc6b 100644 --- a/src/lib/terminal-output-side-effects.ts +++ b/src/lib/terminal-output-side-effects.ts @@ -32,8 +32,11 @@ const LIVE_EXTERNAL_EFFECTS = new Set([ 'local_xterm_notice', ]) +// Truly-idle alerting: green/sound edges for all four terminal CLIs are +// server-emitted (terminal.idle broadcast). The client must never mint a +// completion from output for them; other/custom CLI modes keep the BEL path. function isServerAuthoritativeTurnCompleteMode(mode: string | undefined): boolean { - return mode === 'claude' || mode === 'codex' + return mode === 'claude' || mode === 'codex' || mode === 'opencode' || mode === 'amplifier' } export function shouldAllowTerminalOutputSideEffect( diff --git a/src/store/persistMiddleware.ts b/src/store/persistMiddleware.ts index 22923e04d..6739f5242 100644 --- a/src/store/persistMiddleware.ts +++ b/src/store/persistMiddleware.ts @@ -647,7 +647,6 @@ export const persistMiddleware: Middleware<{}, PersistState> = (store) => { version: 1, attentionByTab: state.turnCompletion?.attentionByTab ?? {}, attentionByPane: state.turnCompletion?.attentionByPane ?? {}, - lastAppliedCompletionSeqByTerminalId: state.turnCompletion?.lastAppliedCompletionSeqByTerminalId ?? {}, }) localStorage.setItem(TURN_COMPLETION_STORAGE_KEY, rawTurnCompletion) broadcastPersistedRaw(TURN_COMPLETION_STORAGE_KEY, rawTurnCompletion) @@ -699,7 +698,6 @@ export const persistMiddleware: Middleware<{}, PersistState> = (store) => { const turnCompletionChanged = ( state.turnCompletion?.attentionByTab !== previousState.turnCompletion?.attentionByTab || state.turnCompletion?.attentionByPane !== previousState.turnCompletion?.attentionByPane - || state.turnCompletion?.lastAppliedCompletionSeqByTerminalId !== previousState.turnCompletion?.lastAppliedCompletionSeqByTerminalId ) if (a.type.startsWith('tabs/') && tabsChanged) { diff --git a/src/store/turnCompletionSlice.ts b/src/store/turnCompletionSlice.ts index 30e01e123..502526701 100644 --- a/src/store/turnCompletionSlice.ts +++ b/src/store/turnCompletionSlice.ts @@ -6,7 +6,6 @@ type TurnCompletePayload = { paneId: string terminalId: string at: number - completionSeq?: number } export type TurnCompleteEvent = TurnCompletePayload & { seq: number } @@ -24,7 +23,6 @@ export interface TurnCompletionState { lastAtByTerminalId: Record /** Truly-idle (terminal.idle) dedupe baseline β€” separate namespace from turn-complete `at`s. */ lastIdleAtByTerminalId: Record - lastAppliedCompletionSeqByTerminalId?: Record pendingEvents: TurnCompleteEvent[] attentionByTab: Record attentionByPane: Record @@ -39,25 +37,13 @@ function readBooleanRecord(value: unknown): Record { return out } -function readNumberRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {} - const out: Record = {} - for (const [key, entry] of Object.entries(value)) { - if (typeof entry === 'number' && Number.isFinite(entry) && entry >= 0) { - out[key] = entry - } - } - return out -} - export function loadPersistedTurnCompletionState(): Pick< TurnCompletionState, - 'attentionByTab' | 'attentionByPane' | 'lastAppliedCompletionSeqByTerminalId' + 'attentionByTab' | 'attentionByPane' > { const empty = { attentionByTab: {}, attentionByPane: {}, - lastAppliedCompletionSeqByTerminalId: {}, } if (typeof localStorage === 'undefined') return empty @@ -69,7 +55,6 @@ export function loadPersistedTurnCompletionState(): Pick< return { attentionByTab: readBooleanRecord(parsed.attentionByTab), attentionByPane: readBooleanRecord(parsed.attentionByPane), - lastAppliedCompletionSeqByTerminalId: readNumberRecord(parsed.lastAppliedCompletionSeqByTerminalId), } } catch { return empty @@ -82,7 +67,6 @@ const initialState: TurnCompletionState = { seq: 0, lastAtByTerminalId: {}, lastIdleAtByTerminalId: {}, - lastAppliedCompletionSeqByTerminalId: persistedTurnCompletion.lastAppliedCompletionSeqByTerminalId, pendingEvents: [], attentionByTab: persistedTurnCompletion.attentionByTab, attentionByPane: persistedTurnCompletion.attentionByPane, @@ -92,22 +76,17 @@ const turnCompletionSlice = createSlice({ name: 'turnCompletion', initialState, reducers: { + // Fresh-agent completion edge (and custom-CLI client BEL path). Terminal CLI + // panes (claude/codex/opencode/amplifier) no longer flow through here β€” their + // bell/shade edge is the server-authoritative terminal.idle (recordTerminalIdle). recordTurnComplete(state, action: PayloadAction) { - const { terminalId, at, completionSeq } = action.payload - if (completionSeq !== undefined) { - const applied = state.lastAppliedCompletionSeqByTerminalId ??= {} - const lastApplied = applied[terminalId] - if (lastApplied !== undefined && completionSeq <= lastApplied) return - applied[terminalId] = completionSeq - state.lastAtByTerminalId[terminalId] = at - } else { - // Monotonic, replay-safe dedupe: only record a completion strictly newer - // than the last seen for this terminal. A replayed/stale completion with - // an older-or-equal `at` is ignored so scrollback replay cannot re-green. - const last = state.lastAtByTerminalId[terminalId] - if (last !== undefined && at <= last) return - state.lastAtByTerminalId[terminalId] = at - } + const { terminalId, at } = action.payload + // Monotonic, replay-safe dedupe: only record a completion strictly newer + // than the last seen for this terminal. A replayed/stale completion with + // an older-or-equal `at` is ignored so scrollback replay cannot re-green. + const last = state.lastAtByTerminalId[terminalId] + if (last !== undefined && at <= last) return + state.lastAtByTerminalId[terminalId] = at state.seq += 1 state.pendingEvents.push({ ...action.payload, diff --git a/src/store/turnCompletionThunks.ts b/src/store/turnCompletionThunks.ts index 978e30a93..e82d4f826 100644 --- a/src/store/turnCompletionThunks.ts +++ b/src/store/turnCompletionThunks.ts @@ -1,4 +1,3 @@ -import type { TerminalTurnCompleteMessage } from '@shared/ws-protocol' import { makeFreshAgentSessionKey } from '@shared/fresh-agent' import { collectPaneEntries } from '@/lib/pane-utils' import { resolveFreshAgentSessionKey } from '@/lib/pane-activity' @@ -7,32 +6,6 @@ import { selectTabPaneByTerminalId } from './selectors/paneTerminalSelectors' import { recordTerminalIdle, recordTurnComplete } from './turnCompletionSlice' import type { AppDispatch, RootState } from './store' -export type ApplyServerCompletionPayload = { - terminalId: string - provider: TerminalTurnCompleteMessage['provider'] - at: number - completionSeq: number -} - -export function applyServerCompletion(payload: ApplyServerCompletionPayload) { - return (dispatch: AppDispatch, getState: () => RootState): void => { - const state = getState() - const lastApplied = state.turnCompletion?.lastAppliedCompletionSeqByTerminalId?.[payload.terminalId] - if (lastApplied !== undefined && payload.completionSeq <= lastApplied) return - - const location = selectTabPaneByTerminalId(state, payload.terminalId) - if (!location) return - - dispatch(recordTurnComplete({ - tabId: location.tabId, - paneId: location.paneId, - terminalId: payload.terminalId, - at: payload.at, - completionSeq: payload.completionSeq, - })) - } -} - export type ApplyServerIdlePayload = { terminalId: string at: number diff --git a/test/e2e/turn-complete-notification-flow.test.tsx b/test/e2e/turn-complete-notification-flow.test.tsx index 54c0e2e18..d186ea267 100644 --- a/test/e2e/turn-complete-notification-flow.test.tsx +++ b/test/e2e/turn-complete-notification-flow.test.tsx @@ -12,7 +12,7 @@ import panesReducer from '@/store/panesSlice' import settingsReducer, { defaultSettings } from '@/store/settingsSlice' import connectionReducer from '@/store/connectionSlice' import turnCompletionReducer from '@/store/turnCompletionSlice' -import { applyServerCompletion } from '@/store/turnCompletionThunks' +import { applyServerIdle } from '@/store/turnCompletionThunks' import { getWsClient } from '@/lib/ws-client' import type { PaneNode, TerminalPaneContent } from '@/store/paneTypes' import type { Tab, AttentionDismiss } from '@/store/types' @@ -124,6 +124,15 @@ class MockResizeObserver { unobserve = vi.fn() } +function emitTerminalIdle(terminalId = 'term-2', at = 1000, reason: 'grace' | 'queue-empty' = 'grace') { + wsMocks.emitMessage({ + type: 'terminal.idle', + terminalId, + at, + reason, + }) +} + function emitCodexTurnComplete(terminalId = 'term-2', at = 1000, completionSeq = 1) { wsMocks.emitMessage({ type: 'terminal.turn.complete', @@ -138,18 +147,17 @@ function Harness() { const dispatch = useAppDispatch() useTurnCompletionNotifications() + // Mirrors App.tsx: the truly-idle edge is the ONLY bell/shade trigger for + // terminal CLI panes; terminal.turn.complete stays informational. useEffect(() => { return getWsClient().onMessage((msg: any) => { - if (msg.type !== 'terminal.turn.complete') return + if (msg.type !== 'terminal.idle') return const terminalId = typeof msg.terminalId === 'string' ? msg.terminalId : '' if (!terminalId) return - const completionSeq = typeof msg.completionSeq === 'number' ? msg.completionSeq : undefined - if (completionSeq === undefined) return - dispatch(applyServerCompletion({ - provider: msg.provider, + dispatch(applyServerIdle({ terminalId, at: typeof msg.at === 'number' ? msg.at : Date.now(), - completionSeq, + reason: msg.reason === 'queue-empty' ? 'queue-empty' : 'grace', }) as any) }) }, [dispatch]) @@ -264,6 +272,7 @@ function createStore(attentionDismiss: AttentionDismiss = 'click') { turnCompletion: { seq: 0, lastAtByTerminalId: {}, + lastIdleAtByTerminalId: {}, pendingEvents: [], attentionByTab: {}, attentionByPane: {}, @@ -318,7 +327,7 @@ describe('turn complete notification flow (e2e)', () => { } }) - it('bells and highlights on background completion, clears on tab click (default click mode)', async () => { + it('never bells or shades on terminal.turn.complete (informational only for terminal CLI panes)', async () => { const store = createStore() render( @@ -335,6 +344,69 @@ describe('turn complete notification flow (e2e)', () => { emitCodexTurnComplete() }) + // No sound, no shade, no pending events β€” only terminal.idle may alert. + expect(playSound).not.toHaveBeenCalled() + expect(store.getState().turnCompletion.pendingEvents).toEqual([]) + expect(store.getState().turnCompletion.attentionByTab['tab-2']).toBeUndefined() + expect(store.getState().turnCompletion.attentionByPane['pane-2']).toBeUndefined() + }) + + it('rings exactly once when a reconnect replays the same terminal.idle edge', async () => { + const store = createStore() + + render( + + + + ) + + await waitFor(() => { + expect(wsMocks.onMessage).toHaveBeenCalled() + }) + + act(() => { + emitTerminalIdle('term-2', 1000) + }) + + await waitFor(() => { + expect(playSound).toHaveBeenCalledTimes(1) + }) + + // Reconnect replay: same edge (same at) and an older edge must be dropped. + act(() => { + emitTerminalIdle('term-2', 1000) + emitTerminalIdle('term-2', 900) + }) + + expect(playSound).toHaveBeenCalledTimes(1) + expect(store.getState().turnCompletion.seq).toBe(1) + + // A genuinely newer edge still rings. + act(() => { + emitTerminalIdle('term-2', 2000, 'queue-empty') + }) + await waitFor(() => { + expect(playSound).toHaveBeenCalledTimes(2) + }) + }) + + it('bells and highlights on background completion, clears on tab click (default click mode)', async () => { + const store = createStore() + + render( + + + + ) + + await waitFor(() => { + expect(wsMocks.onMessage).toHaveBeenCalled() + }) + + act(() => { + emitTerminalIdle() + }) + await waitFor(() => { expect(playSound).toHaveBeenCalledTimes(1) }) @@ -372,7 +444,7 @@ describe('turn complete notification flow (e2e)', () => { // Emit turn complete signal on background tab's terminal act(() => { - emitCodexTurnComplete() + emitTerminalIdle() }) // Both tab and pane attention should be set @@ -418,7 +490,7 @@ describe('turn complete notification flow (e2e)', () => { // Emit turn complete signal on tab-2 (the now-active tab) act(() => { - emitCodexTurnComplete() + emitTerminalIdle() }) // Tab-2 should have attention @@ -450,7 +522,7 @@ describe('turn complete notification flow (e2e)', () => { // Emit turn complete signal on background tab's terminal act(() => { - emitCodexTurnComplete() + emitTerminalIdle() }) await waitFor(() => { diff --git a/test/unit/client/components/App.ws-bootstrap.test.tsx b/test/unit/client/components/App.ws-bootstrap.test.tsx index 1f1bcac5c..34d290de3 100644 --- a/test/unit/client/components/App.ws-bootstrap.test.tsx +++ b/test/unit/client/components/App.ws-bootstrap.test.tsx @@ -247,7 +247,7 @@ function createStore(options?: { turnCompletion: { seq: 0, lastAtByTerminalId: {}, - lastAppliedCompletionSeqByTerminalId: {}, + lastIdleAtByTerminalId: {}, pendingEvents: [], attentionByTab: {}, attentionByPane: {}, @@ -1335,7 +1335,7 @@ describe('App WS bootstrap recovery', () => { }) }) - it('records OpenCode turn completion from the production WebSocket message path', async () => { + it('records the terminal.idle edge from the production WebSocket message path', async () => { const store = createStore({ tabs: [{ id: 'tab-opencode', @@ -1381,6 +1381,7 @@ describe('App WS bootstrap recovery', () => { expect(store.getState().connection.status).toBe('ready') }) + // terminal.turn.complete is informational for terminal CLI panes: no bell/shade. act(() => { messageHandler?.({ type: 'terminal.turn.complete', @@ -1391,17 +1392,28 @@ describe('App WS bootstrap recovery', () => { completionSeq: 5, }) }) + expect(store.getState().turnCompletion.pendingEvents).toEqual([]) + expect(store.getState().turnCompletion.seq).toBe(0) + + // The truly-idle edge is the ONLY bell/shade trigger. + act(() => { + messageHandler?.({ + type: 'terminal.idle', + terminalId: 'term-opencode', + at: 2345, + reason: 'grace', + }) + }) await waitFor(() => { expect(store.getState().turnCompletion.attentionByPane['pane-opencode']).toBe(true) }) expect(store.getState().turnCompletion.attentionByTab['tab-opencode']).toBe(true) - expect(store.getState().turnCompletion.lastAtByTerminalId['term-opencode']).toBe(1234) - expect(store.getState().turnCompletion.lastAppliedCompletionSeqByTerminalId?.['term-opencode']).toBe(5) + expect(store.getState().turnCompletion.lastIdleAtByTerminalId['term-opencode']).toBe(2345) expect(store.getState().turnCompletion.seq).toBe(1) }) - it('applies latest turn completions from activity list responses once across reconnect refreshes', async () => { + it('ignores latestTurnCompletions in activity list responses (turn completions no longer ring or shade)', async () => { const store = createStore({ tabs: [{ id: 'tab-opencode', @@ -1466,50 +1478,15 @@ describe('App WS bootstrap recovery', () => { }) }) - await waitFor(() => { - expect(store.getState().turnCompletion.attentionByPane['pane-opencode']).toBe(true) - }) - expect(store.getState().turnCompletion.lastAppliedCompletionSeqByTerminalId?.['term-opencode']).toBe(7) - expect(store.getState().turnCompletion.seq).toBe(1) - - act(() => { - messageHandler?.({ - type: 'ready', - timestamp: new Date().toISOString(), - serverInstanceId: 'srv-preconnected-opencode-latest-completion', - }) - }) - - await waitFor(() => { - const opencodeRequests = wsMocks.send.mock.calls - .map(([payload]) => payload) - .filter((payload) => payload?.type === 'opencode.activity.list') - expect(opencodeRequests.length).toBeGreaterThanOrEqual(2) - }) - const secondRequestId = wsMocks.send.mock.calls - .map(([payload]) => payload) - .filter((payload) => payload?.type === 'opencode.activity.list') - .at(-1)?.requestId as string - - act(() => { - messageHandler?.({ - type: 'opencode.activity.list.response', - requestId: secondRequestId, - terminals: [], - latestTurnCompletions: [{ - terminalId: 'term-opencode', - at: 2_000, - completionSeq: 7, - }], - }) - }) - - expect(store.getState().turnCompletion.seq).toBe(1) - expect(store.getState().turnCompletion.attentionByPane['pane-opencode']).toBe(true) - expect(store.getState().turnCompletion.lastAppliedCompletionSeqByTerminalId?.['term-opencode']).toBe(7) + // Turn completions are informational: no bell, no shade, no pending events β€” + // the truly-idle terminal.idle edge is the only alerting trigger. + expect(store.getState().turnCompletion.pendingEvents).toEqual([]) + expect(store.getState().turnCompletion.seq).toBe(0) + expect(store.getState().turnCompletion.attentionByPane['pane-opencode']).toBeUndefined() + expect(store.getState().turnCompletion.attentionByTab['tab-opencode']).toBeUndefined() }) - it('records OpenCode turn completion against the active tab when a terminal is duplicated', async () => { + it('records the terminal.idle edge against the active tab when a terminal is duplicated', async () => { const store = createStore({ activeTabId: 'tab-active', tabs: [ @@ -1584,12 +1561,10 @@ describe('App WS bootstrap recovery', () => { act(() => { messageHandler?.({ - type: 'terminal.turn.complete', + type: 'terminal.idle', terminalId: 'term-opencode', - provider: 'opencode', - sessionId: 'session-opencode', at: 5678, - completionSeq: 6, + reason: 'queue-empty', }) }) @@ -1597,8 +1572,7 @@ describe('App WS bootstrap recovery', () => { expect(store.getState().turnCompletion.attentionByPane['pane-active']).toBe(true) }) expect(store.getState().turnCompletion.attentionByTab['tab-active']).toBe(true) - expect(store.getState().turnCompletion.lastAtByTerminalId['term-opencode']).toBe(5678) - expect(store.getState().turnCompletion.lastAppliedCompletionSeqByTerminalId?.['term-opencode']).toBe(6) + expect(store.getState().turnCompletion.lastIdleAtByTerminalId['term-opencode']).toBe(5678) expect(store.getState().turnCompletion.seq).toBe(1) }) diff --git a/test/unit/client/lib/terminal-output-side-effects.test.ts b/test/unit/client/lib/terminal-output-side-effects.test.ts index b34b8a26d..2624154ec 100644 --- a/test/unit/client/lib/terminal-output-side-effects.test.ts +++ b/test/unit/client/lib/terminal-output-side-effects.test.ts @@ -82,7 +82,7 @@ describe('terminal output side-effect policy', () => { terminalInstanceId: 'surface-live-frame', source: 'live', effect: 'turn_complete', - mode: 'opencode', + mode: 'gemini', })).toBe(true) expect(shouldAllowTerminalOutputSideEffect({ terminalInstanceId: 'surface-live-frame', @@ -95,26 +95,26 @@ describe('terminal output side-effect policy', () => { } }) - it('keeps server-authoritative turn completion for Claude and Codex', () => { + it('keeps server-authoritative turn completion for all four terminal CLIs', () => { + // Truly-idle alerting: claude/codex/opencode/amplifier green/sound edges are + // server-emitted (terminal.idle) β€” the client must not mint completions from + // output for any of them. Other modes (custom CLIs) keep the client BEL path. + for (const mode of ['claude', 'codex', 'opencode', 'amplifier'] as const) { + expect(shouldAllowTerminalOutputSideEffect({ + source: 'live', + effect: 'turn_complete', + mode, + })).toBe(false) + } expect(shouldAllowTerminalOutputSideEffect({ source: 'live', effect: 'turn_complete', - mode: 'opencode', + mode: 'gemini', })).toBe(true) - expect(shouldAllowTerminalOutputSideEffect({ - source: 'live', - effect: 'turn_complete', - mode: 'claude', - })).toBe(false) - expect(shouldAllowTerminalOutputSideEffect({ - source: 'live', - effect: 'turn_complete', - mode: 'codex', - })).toBe(false) expect(shouldAllowTerminalOutputSideEffect({ source: 'replay', effect: 'turn_complete', - mode: 'opencode', + mode: 'gemini', })).toBe(false) }) diff --git a/test/unit/client/store/turnCompletionPersistence.test.ts b/test/unit/client/store/turnCompletionPersistence.test.ts index 3267229e7..8dfe8c25c 100644 --- a/test/unit/client/store/turnCompletionPersistence.test.ts +++ b/test/unit/client/store/turnCompletionPersistence.test.ts @@ -15,12 +15,11 @@ describe('turnCompletion persistence', () => { vi.resetModules() }) - it('persists attention and applied completion sequence via persistMiddleware', async () => { + it('persists attention via persistMiddleware', async () => { const { default: turnCompletionReducer, markPaneAttention, markTabAttention, - recordTurnComplete, } = await importTurnCompletionSlice() const store = configureStore({ reducer: { @@ -31,28 +30,21 @@ describe('turnCompletion persistence', () => { store.dispatch(markTabAttention({ tabId: 'tab-1' })) store.dispatch(markPaneAttention({ paneId: 'pane-1' })) - store.dispatch(recordTurnComplete({ - tabId: 'tab-1', - paneId: 'pane-1', - terminalId: 'term-1', - at: 1_000, - completionSeq: 4, - })) vi.advanceTimersByTime(PERSIST_DEBOUNCE_MS) expect(JSON.parse(localStorage.getItem(TURN_COMPLETION_STORAGE_KEY) || '{}')).toEqual({ version: 1, attentionByTab: { 'tab-1': true }, attentionByPane: { 'pane-1': true }, - lastAppliedCompletionSeqByTerminalId: { 'term-1': 4 }, }) }) - it('rehydrates attention and applied completion sequence from persisted state', async () => { + it('rehydrates attention from persisted state (legacy completionSeq field tolerated)', async () => { localStorage.setItem(TURN_COMPLETION_STORAGE_KEY, JSON.stringify({ version: 1, attentionByTab: { 'tab-1': true }, attentionByPane: { 'pane-1': true }, + // Written by older builds; must be ignored, not rejected. lastAppliedCompletionSeqByTerminalId: { 'term-1': 4 }, })) @@ -61,7 +53,6 @@ describe('turnCompletion persistence', () => { expect(state.attentionByTab).toEqual({ 'tab-1': true }) expect(state.attentionByPane).toEqual({ 'pane-1': true }) - expect(state.lastAppliedCompletionSeqByTerminalId).toEqual({ 'term-1': 4 }) expect(state.pendingEvents).toEqual([]) }) }) diff --git a/test/unit/client/store/turnCompletionSlice.test.ts b/test/unit/client/store/turnCompletionSlice.test.ts index f6ba4c395..286296210 100644 --- a/test/unit/client/store/turnCompletionSlice.test.ts +++ b/test/unit/client/store/turnCompletionSlice.test.ts @@ -15,7 +15,7 @@ import panesReducer from '@/store/panesSlice' import tabsReducer, { closePaneWithCleanup, closeTab } from '@/store/tabsSlice' import settingsReducer, { defaultSettings } from '@/store/settingsSlice' import type { PaneNode } from '@/store/paneTypes' -import { applyServerCompletion } from '@/store/turnCompletionThunks' +import { applyServerIdle } from '@/store/turnCompletionThunks' describe('turnCompletionSlice', () => { it('records latest event with sequence id', () => { @@ -79,92 +79,6 @@ describe('turnCompletionSlice', () => { expect(state.pendingEvents).toHaveLength(2) }) - it('dedupes server completions by per-terminal completionSeq while keeping local pending-event seq separate', () => { - let state = reducer( - undefined, - recordTurnComplete({ - tabId: 'tab-1', - paneId: 'pane-1', - terminalId: 'term-1', - at: 1_000, - completionSeq: 7, - }), - ) - - expect(state.pendingEvents).toEqual([{ - tabId: 'tab-1', - paneId: 'pane-1', - terminalId: 'term-1', - at: 1_000, - completionSeq: 7, - seq: 1, - }]) - expect(state.lastAppliedCompletionSeqByTerminalId?.['term-1']).toBe(7) - - state = reducer( - state, - recordTurnComplete({ - tabId: 'tab-1', - paneId: 'pane-1', - terminalId: 'term-1', - at: 1_100, - completionSeq: 7, - }), - ) - state = reducer( - state, - recordTurnComplete({ - tabId: 'tab-1', - paneId: 'pane-1', - terminalId: 'term-1', - at: 1_200, - completionSeq: 6, - }), - ) - - expect(state.pendingEvents).toHaveLength(1) - expect(state.seq).toBe(1) - - state = reducer( - state, - recordTurnComplete({ - tabId: 'tab-1', - paneId: 'pane-1', - terminalId: 'term-1', - at: 1_300, - completionSeq: 8, - }), - ) - - expect(state.pendingEvents).toHaveLength(2) - expect(state.pendingEvents[1]?.seq).toBe(2) - expect(state.lastAppliedCompletionSeqByTerminalId?.['term-1']).toBe(8) - }) - - it('drops a server completion that is already covered by rehydrated completionSeq state', () => { - const state = reducer( - { - seq: 0, - lastAtByTerminalId: {}, - lastAppliedCompletionSeqByTerminalId: { 'term-1': 9 }, - pendingEvents: [], - attentionByTab: {}, - attentionByPane: {}, - }, - recordTurnComplete({ - tabId: 'tab-1', - paneId: 'pane-1', - terminalId: 'term-1', - at: 1_000, - completionSeq: 9, - }), - ) - - expect(state.pendingEvents).toEqual([]) - expect(state.seq).toBe(0) - expect(state.lastAppliedCompletionSeqByTerminalId?.['term-1']).toBe(9) - }) - it('resetCompletionDedupeBaselines clears the at baseline (so a post-restart lower at re-fires) but preserves attention', () => { // Across a real server restart the client store survives, but the fresh process may // stamp a lower wall-clock `at` than the (possibly clamp-inflated) pre-restart value. @@ -221,7 +135,6 @@ describe('turnCompletionSlice', () => { seq: 0, lastAtByTerminalId: {}, lastIdleAtByTerminalId: {}, - lastAppliedCompletionSeqByTerminalId: {}, pendingEvents: [], attentionByTab: {}, attentionByPane: {}, @@ -351,7 +264,7 @@ describe('turnCompletionSlice', () => { }) }) - describe('applyServerCompletion thunk', () => { + describe('applyServerIdle thunk', () => { function createTerminalStore(terminalId = 'term-1') { const now = Date.now() return configureStore({ @@ -394,7 +307,7 @@ describe('turnCompletionSlice', () => { turnCompletion: { seq: 0, lastAtByTerminalId: {}, - lastAppliedCompletionSeqByTerminalId: {}, + lastIdleAtByTerminalId: {}, pendingEvents: [], attentionByTab: {}, attentionByPane: {}, @@ -403,20 +316,19 @@ describe('turnCompletionSlice', () => { }) } - it('resolves the owning pane and applies a newer server completion once', () => { + it('resolves the owning pane and applies a newer terminal.idle edge once', () => { const store = createTerminalStore() - store.dispatch(applyServerCompletion({ - provider: 'opencode', + store.dispatch(applyServerIdle({ terminalId: 'term-1', at: 1_000, - completionSeq: 3, + reason: 'grace', }) as any) - store.dispatch(applyServerCompletion({ - provider: 'opencode', + // Replayed/stale edge with an older-or-equal at is dropped. + store.dispatch(applyServerIdle({ terminalId: 'term-1', - at: 1_100, - completionSeq: 3, + at: 1_000, + reason: 'grace', }) as any) expect(store.getState().turnCompletion.pendingEvents).toEqual([{ @@ -424,24 +336,22 @@ describe('turnCompletionSlice', () => { paneId: 'pane-1', terminalId: 'term-1', at: 1_000, - completionSeq: 3, seq: 1, }]) - expect(store.getState().turnCompletion.lastAppliedCompletionSeqByTerminalId?.['term-1']).toBe(3) + expect(store.getState().turnCompletion.lastIdleAtByTerminalId?.['term-1']).toBe(1_000) }) - it('does not consume server completionSeq when no pane owns the terminal yet', () => { + it('does not consume the idle baseline when no pane owns the terminal yet', () => { const store = createTerminalStore('term-owned') - store.dispatch(applyServerCompletion({ - provider: 'codex', + store.dispatch(applyServerIdle({ terminalId: 'term-missing', at: 1_000, - completionSeq: 1, + reason: 'grace', }) as any) expect(store.getState().turnCompletion.pendingEvents).toEqual([]) - expect(store.getState().turnCompletion.lastAppliedCompletionSeqByTerminalId?.['term-missing']).toBeUndefined() + expect(store.getState().turnCompletion.lastIdleAtByTerminalId?.['term-missing']).toBeUndefined() }) }) From 45a092a9ac5d1a2686a2cefb049f9f3a01470251 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 24 Jul 2026 00:18:51 -0700 Subject: [PATCH 6/8] feat(client): persistent idle green for terminal CLI panes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolvePaneIdleGreen: green when the pane's CLI session is known (activity record, bound sessionRef, or resume id) and the pane is not busy. Replaces the one-shot needs-attention green for claude/codex/opencode/amplifier panes; fresh-agent panes keep the attention-based green. Independent of tab shading and its click-clearing. πŸ€– Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- src/components/panes/PaneContainer.tsx | 17 ++- src/lib/pane-activity.ts | 56 +++++++ .../e2e/pane-activity-indicator-flow.test.tsx | 43 ++++++ test/unit/client/lib/pane-activity.test.ts | 142 +++++++++++++++++- 4 files changed, 253 insertions(+), 5 deletions(-) diff --git a/src/components/panes/PaneContainer.tsx b/src/components/panes/PaneContainer.tsx index 5eedfe5ae..0c9494a7c 100644 --- a/src/components/panes/PaneContainer.tsx +++ b/src/components/panes/PaneContainer.tsx @@ -21,7 +21,7 @@ import { cn } from '@/lib/utils' import { withChunkErrorRecovery } from '@/lib/import-retry' import { getWsClient } from '@/lib/ws-client' import { api } from '@/lib/api' -import { resolvePaneActivity } from '@/lib/pane-activity' +import { isTrulyIdleCliMode, resolvePaneActivity, resolvePaneIdleGreen } from '@/lib/pane-activity' import { getPaneDisplayTitle } from '@/lib/pane-title' import { getTabDirectoryPreference } from '@/lib/tab-directory-preference' import { @@ -515,7 +515,7 @@ export default function PaneContainer({ tabId, node, hidden }: PaneContainerProp paneRuntimeMeta ? formatPaneRuntimeTooltip(paneRuntimeMeta) : undefined - const paneBusy = resolvePaneActivity({ + const paneActivityInput = { paneId: node.id, content: node.content, tabMode: tab?.mode, @@ -526,9 +526,18 @@ export default function PaneContainer({ tabId, node, hidden }: PaneContainerProp opencodeActivityByTerminalId, paneRuntimeActivityByPaneId, freshAgentSessions, - }).isBusy + } + const paneBusy = resolvePaneActivity(paneActivityInput).isBusy - const needsAttention = tabAttentionStyle !== 'none' && !!attentionByPane[node.id] + // Terminal CLI panes (claude/codex/opencode/amplifier): green is a PERSISTENT + // idle state (session known and not busy), independent of tab shading and its + // click-clearing. Everything else keeps the one-shot needs-attention green. + const effectiveTerminalMode = node.content.kind === 'terminal' + ? (node.content.mode !== 'shell' ? node.content.mode : tab?.mode) + : undefined + const needsAttention = node.content.kind === 'terminal' && isTrulyIdleCliMode(effectiveTerminalMode) + ? resolvePaneIdleGreen(paneActivityInput) + : tabAttentionStyle !== 'none' && !!attentionByPane[node.id] const refreshTarget = buildPaneRefreshTarget(node.content) const handleRefresh = refreshTarget diff --git a/src/lib/pane-activity.ts b/src/lib/pane-activity.ts index 1bab1d10a..043087309 100644 --- a/src/lib/pane-activity.ts +++ b/src/lib/pane-activity.ts @@ -196,6 +196,62 @@ export function resolvePaneActivity(input: { return IDLE_PANE_ACTIVITY } +const TRULY_IDLE_CLI_MODES = new Set(['claude', 'codex', 'opencode', 'amplifier']) + +/** Terminal CLI modes whose alerting (green/bell/shade) is server-authoritative. */ +export function isTrulyIdleCliMode(mode: string | undefined): boolean { + return mode !== undefined && TRULY_IDLE_CLI_MODES.has(mode) +} + +/** + * Persistent green for terminal CLI panes (claude/codex/opencode/amplifier): + * shown whenever the pane's CLI session is known and the pane is not busy. + * Replaces the one-shot needs-attention green for these panes; fresh-agent + * panes keep the attention-based green. + * + * "Session known" = an activity record exists for the terminal (claude/amplifier + * track from creation, codex from session binding) OR the pane content carries a + * bound sessionRef / resumeSessionId (opencode has no idle-phase records β€” its + * records exist only while busy). + */ +export function resolvePaneIdleGreen(input: { + paneId: string + content: PaneContent + tabMode?: Tab['mode'] + isOnlyPane: boolean + codexActivityByTerminalId: Record + opencodeActivityByTerminalId: Record + claudeActivityByTerminalId: Record + amplifierActivityByTerminalId: Record + paneRuntimeActivityByPaneId: Record + freshAgentSessions?: Record +}): boolean { + if (input.content.kind !== 'terminal') return false + if (input.content.status !== 'running') return false + + const effectiveMode = input.content.mode !== 'shell' + ? input.content.mode + : input.tabMode + if (!isTrulyIdleCliMode(effectiveMode)) return false + + if (resolvePaneActivity(input).isBusy) return false + + const terminalId = input.content.terminalId + const record = terminalId + ? (effectiveMode === 'codex' && input.codexActivityByTerminalId[terminalId]) + || (effectiveMode === 'claude' && input.claudeActivityByTerminalId[terminalId]) + || (effectiveMode === 'amplifier' && input.amplifierActivityByTerminalId[terminalId]) + || (effectiveMode === 'opencode' && input.opencodeActivityByTerminalId[terminalId]) + || undefined + : undefined + + return Boolean( + record + || input.content.sessionRef?.sessionId + || input.content.resumeSessionId, + ) +} + export function getBusyPaneIdsForTab(input: { tab: Tab paneLayouts: Record diff --git a/test/e2e/pane-activity-indicator-flow.test.tsx b/test/e2e/pane-activity-indicator-flow.test.tsx index df4ea069f..6ef115704 100644 --- a/test/e2e/pane-activity-indicator-flow.test.tsx +++ b/test/e2e/pane-activity-indicator-flow.test.tsx @@ -390,6 +390,49 @@ describe('pane activity indicator flow (e2e)', () => { expect(within(getVisibleSinglePaneTab()).getByTestId('pane-icon').getAttribute('class') ?? '').not.toContain('text-blue-500') }) + it('shows persistent idle green on a claude pane header whenever the session is known and not busy', () => { + const pane: TerminalPaneContent = { + kind: 'terminal', + createRequestId: 'req-claude', + status: 'running', + mode: 'claude', + shell: 'system', + terminalId: 'term-claude', + resumeSessionId: '11111111-1111-4111-8111-111111111111', + } + + const { store } = renderHarness({ + pane, + tab: { + mode: 'claude', + terminalId: 'term-claude', + resumeSessionId: '11111111-1111-4111-8111-111111111111', + }, + }) + + const paneHeader = screen.getByRole('banner', { name: 'Pane: Activity Pane' }) + // Session known (resumeSessionId) and not busy -> persistent green, no + // one-shot attention event required. + expect(paneHeader.getAttribute('class')).toContain('bg-emerald-50') + + // Busy -> blue wins, green clears. + act(() => { + store.dispatch(upsertClaudeActivity({ + terminals: [{ terminalId: 'term-claude', phase: 'busy', updatedAt: 1 }], + })) + }) + expect(paneHeader.getAttribute('class') ?? '').not.toContain('bg-emerald-50') + + // Idle again -> green returns without any attention event (persistent state, + // independent of tab shading and click-clearing). + act(() => { + store.dispatch(upsertClaudeActivity({ + terminals: [{ terminalId: 'term-claude', phase: 'idle', updatedAt: 2 }], + })) + }) + expect(paneHeader.getAttribute('class')).toContain('bg-emerald-50') + }) + it('keeps a claude pane blue across a transport reconnect (rehydrates busy from the server snapshot)', () => { const pane: TerminalPaneContent = { kind: 'terminal', diff --git a/test/unit/client/lib/pane-activity.test.ts b/test/unit/client/lib/pane-activity.test.ts index c6a0cf243..73ec4ff44 100644 --- a/test/unit/client/lib/pane-activity.test.ts +++ b/test/unit/client/lib/pane-activity.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { collectBusySessionKeys, resolvePaneActivity } from '@/lib/pane-activity' +import { collectBusySessionKeys, resolvePaneActivity, resolvePaneIdleGreen } from '@/lib/pane-activity' import type { PaneNode, TerminalPaneContent } from '@/store/paneTypes' import type { FreshAgentSessionState } from '@/store/freshAgentTypes' import type { Tab } from '@/store/types' @@ -478,4 +478,144 @@ describe('pane activity', () => { expect(resolvePaneActivity({ ...base, amplifierActivityByTerminalId: { t1: { terminalId: 't1', phase: 'idle', updatedAt: 1 } } }).isBusy).toBe(false) expect(resolvePaneActivity({ ...base, amplifierActivityByTerminalId: {} }).isBusy).toBe(false) }) + + describe('resolvePaneIdleGreen (persistent green for terminal CLI panes)', () => { + const emptyMaps = { + codexActivityByTerminalId: {}, + opencodeActivityByTerminalId: {}, + claudeActivityByTerminalId: {}, + amplifierActivityByTerminalId: {}, + paneRuntimeActivityByPaneId: {}, + } + + function terminalContent(overrides: Partial = {}): TerminalPaneContent { + return { + kind: 'terminal', + createRequestId: 'c1', + status: 'running', + mode: 'claude', + shell: 'system', + terminalId: 't1', + ...overrides, + } as TerminalPaneContent + } + + it('is green when the CLI session is known (idle activity record) and not busy', () => { + expect(resolvePaneIdleGreen({ + paneId: 'p1', + content: terminalContent(), + isOnlyPane: true, + ...emptyMaps, + claudeActivityByTerminalId: { t1: { terminalId: 't1', phase: 'idle', updatedAt: 1 } }, + })).toBe(true) + }) + + it('is not green while busy (blue wins)', () => { + expect(resolvePaneIdleGreen({ + paneId: 'p1', + content: terminalContent(), + isOnlyPane: true, + ...emptyMaps, + claudeActivityByTerminalId: { t1: { terminalId: 't1', phase: 'busy', updatedAt: 1 } }, + })).toBe(false) + }) + + it('is not green when no session is known yet (no record, no sessionRef, no resume id)', () => { + expect(resolvePaneIdleGreen({ + paneId: 'p1', + content: terminalContent(), + isOnlyPane: true, + ...emptyMaps, + })).toBe(false) + }) + + it('treats a bound sessionRef or resumeSessionId as a known session (opencode has no idle records)', () => { + expect(resolvePaneIdleGreen({ + paneId: 'p1', + content: terminalContent({ + mode: 'opencode', + sessionRef: { provider: 'opencode', sessionId: 'ses-1' }, + }), + isOnlyPane: true, + ...emptyMaps, + })).toBe(true) + expect(resolvePaneIdleGreen({ + paneId: 'p1', + content: terminalContent({ mode: 'opencode', resumeSessionId: 'ses-1' }), + isOnlyPane: true, + ...emptyMaps, + })).toBe(true) + }) + + it('is not green for a busy opencode terminal even with a bound session', () => { + expect(resolvePaneIdleGreen({ + paneId: 'p1', + content: terminalContent({ + mode: 'opencode', + sessionRef: { provider: 'opencode', sessionId: 'ses-1' }, + }), + isOnlyPane: true, + ...emptyMaps, + opencodeActivityByTerminalId: { t1: { terminalId: 't1', phase: 'busy', updatedAt: 1, lastObservedAt: 1 } as any }, + })).toBe(false) + }) + + it('is not green for codex while pending (queued submit is busy)', () => { + expect(resolvePaneIdleGreen({ + paneId: 'p1', + content: terminalContent({ mode: 'codex' }), + isOnlyPane: true, + ...emptyMaps, + codexActivityByTerminalId: { t1: { terminalId: 't1', phase: 'pending', updatedAt: 1 } }, + })).toBe(false) + expect(resolvePaneIdleGreen({ + paneId: 'p1', + content: terminalContent({ mode: 'codex' }), + isOnlyPane: true, + ...emptyMaps, + codexActivityByTerminalId: { t1: { terminalId: 't1', phase: 'idle', updatedAt: 1 } }, + })).toBe(true) + }) + + it('is never green for shell panes, exited terminals, or non-terminal panes', () => { + expect(resolvePaneIdleGreen({ + paneId: 'p1', + content: terminalContent({ mode: 'shell' }), + isOnlyPane: true, + ...emptyMaps, + })).toBe(false) + expect(resolvePaneIdleGreen({ + paneId: 'p1', + content: terminalContent({ status: 'exited' }), + isOnlyPane: true, + ...emptyMaps, + claudeActivityByTerminalId: { t1: { terminalId: 't1', phase: 'idle', updatedAt: 1 } }, + })).toBe(false) + expect(resolvePaneIdleGreen({ + paneId: 'p1', + content: { kind: 'browser', url: 'https://example.com' } as any, + isOnlyPane: true, + ...emptyMaps, + })).toBe(false) + }) + + it('is never green for custom CLI modes (gemini/kimi are status-inert)', () => { + expect(resolvePaneIdleGreen({ + paneId: 'p1', + content: terminalContent({ mode: 'gemini', resumeSessionId: 'ses-1' }), + isOnlyPane: true, + ...emptyMaps, + })).toBe(false) + }) + + it('is green for an amplifier pane with an idle activity record', () => { + expect(resolvePaneIdleGreen({ + paneId: 'p1', + content: terminalContent({ mode: 'amplifier' }), + isOnlyPane: true, + ...emptyMaps, + amplifierActivityByTerminalId: { t1: { terminalId: 't1', phase: 'idle', updatedAt: 1 } }, + })).toBe(true) + }) + }) }) From a1567ba8dfaa60a7d9f5885934241b65d2adebff Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 24 Jul 2026 00:53:13 -0700 Subject: [PATCH 7/8] test(e2e): truly-idle alerting Playwright spec (legacy leg; rust leg fixme) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fake CLAUDE_CMD CLI drives the full pipeline with zero Redux shortcuts: PTY submit -> blue; BEL + quiet 2s grace -> terminal.idle -> persistent green header + tab shade + exactly one alert edge; activating the tab clears the shade while green persists. Added to MATRIX_SPECS; the rust leg is test.fixme pending the rust terminal.idle emitter (feat/rust-terminal-activity-idle). πŸ€– Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- test/e2e-browser/playwright.config.ts | 5 + .../specs/truly-idle-alerting.spec.ts | 191 ++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 test/e2e-browser/specs/truly-idle-alerting.spec.ts diff --git a/test/e2e-browser/playwright.config.ts b/test/e2e-browser/playwright.config.ts index 59e37dbbd..57c55e095 100644 --- a/test/e2e-browser/playwright.config.ts +++ b/test/e2e-browser/playwright.config.ts @@ -64,6 +64,11 @@ const MATRIX_SPECS = [ /safe01-auth-matrix\.spec\.ts$/, /safe03-origin-matrix\.spec\.ts$/, /cfg03-backup-restore\.spec\.ts$/, + // Truly-idle alerting (terminal.idle): end-to-end blue -> green + one alert + // edge + tab shade -> activate clears. Legacy leg runs; the rust leg is + // test.fixme pending the rust terminal.idle emitter + // (feat/rust-terminal-activity-idle) so it flips on trivially. + /truly-idle-alerting\.spec\.ts$/, // AGENT-14 -- checkpoint create/list/metadata/restore driven through the // real "Rewind code to here" UI gesture (hover, click, confirm dialog, // POST restore, verify file bytes). Legacy is a true parity control: the diff --git a/test/e2e-browser/specs/truly-idle-alerting.spec.ts b/test/e2e-browser/specs/truly-idle-alerting.spec.ts new file mode 100644 index 000000000..d5503cb34 --- /dev/null +++ b/test/e2e-browser/specs/truly-idle-alerting.spec.ts @@ -0,0 +1,191 @@ +// TRULY-IDLE ALERTING (terminal.idle) -- end-to-end proof on the legacy Node +// server, matrix-style so the Rust leg flips on trivially once the Rust +// terminal.idle emitter lands (feat/rust-terminal-activity-idle). +// +// Drives a REAL claude-mode terminal pane whose CLI is a deterministic fake +// (`CLAUDE_CMD` override, same pattern as restore-matrix.spec.ts): the fake +// stays interactive, and on each submitted line "works" for a fixed window +// before emitting a tracker-eligible Stop-hook BEL. That exercises the entire +// production pipeline with zero Redux shortcuts: +// +// PTY submit -> claude tracker busy -> BLUE +// BEL -> tracker idle + turn.complete -> TrulyIdleEmitter grace (2s, quiet) +// -> ws `terminal.idle` broadcast -> exactly ONE alert edge +// -> persistent GREEN pane header + tab SHADING +// activate tab -> shading clears; green persists +// +// The audible bell itself is unit-covered (useTurnCompletionNotifications); +// here "one bell" is proven as exactly one alert edge entering the pipeline +// (turnCompletion.seq === 1, stable across an extra settle window). + +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { test, expect } from '../helpers/fixtures.js' +import { createE2eServerHandle, type E2eServerHandle } from '../helpers/external-target.js' +import { TestHarness } from '../helpers/test-harness.js' + +// How long the fake CLI "works" after each submit before emitting its BEL. +// Long enough for Playwright to observe BLUE deterministically, short enough +// to keep the spec fast (turn end + 2s truly-idle grace still fits timeouts). +const FAKE_TURN_MS = 4_000 + +/** + * Deterministic fake `claude` CLI. Interactive (never exits on its own); on + * every line of stdin (the pane's Enter submit -- cooked-mode line discipline + * withholds bytes until then) it waits FAKE_CLAUDE_TURN_MS, prints a turn-end + * marker, then writes a lone BEL chunk -- the same tracker-eligible shape the + * real CLI's injected Stop hook produces (`countTrackerTurnCompleteSignals`: + * a BEL with no visible output after it in its chunk). + */ +async function installFakeClaudeCli(destDir: string): Promise { + await fs.mkdir(destDir, { recursive: true }) + const dest = path.join(destDir, 'fake-claude-cli.mjs') + const script = `#!/usr/bin/env node +const turnMs = Number(process.env.FAKE_CLAUDE_TURN_MS || '${FAKE_TURN_MS}') +process.stdout.write('fake-claude ready\\r\\n') +process.stdin.setEncoding('utf8') +process.stdin.on('data', () => { + setTimeout(() => { + process.stdout.write('fake-claude turn done\\r\\n') + setTimeout(() => process.stdout.write('\\x07'), 50) + }, turnMs) +}) +process.stdin.resume() +` + await fs.writeFile(dest, script, 'utf8') + await fs.chmod(dest, 0o755) + return dest +} + +function findTerminalLeaf(node: any): any { + if (!node) return null + if (node.type === 'leaf' && node.content?.kind === 'terminal') return node + if (node.type === 'split') { + for (const child of node.children ?? []) { + const found = findTerminalLeaf(child) + if (found) return found + } + } + return null +} + +test.describe('Truly-idle alerting (terminal.idle)', () => { + test.setTimeout(180_000) + + test('claude terminal: blue while busy, then green + one alert edge + tab shade after quiet grace; activating the tab clears the shade', async ({ page, e2eServerKind }) => { + test.fixme( + e2eServerKind === 'rust', + 'pending rust terminal.idle emitter β€” feat/rust-terminal-activity-idle', + ) + + const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-truly-idle-')) + let server: E2eServerHandle | undefined + try { + const fakeClaudePath = await installFakeClaudeCli(path.join(sharedRoot, 'bin')) + + server = await createE2eServerHandle(process.env, { + kind: e2eServerKind, + construct: { + env: { + CLAUDE_CMD: fakeClaudePath, + FAKE_CLAUDE_TURN_MS: String(FAKE_TURN_MS), + }, + // PanePicker renders a CLI option only when availableClis[name] + // (server `which` probe honors the CLAUDE_CMD override) AND + // codingCli.enabledProviders includes it -- seed the latter. + setupHome: async (homeDir) => { + const freshellDir = path.join(homeDir, '.freshell') + await fs.mkdir(freshellDir, { recursive: true }) + await fs.writeFile(path.join(freshellDir, 'config.json'), JSON.stringify({ + version: 1, + settings: { + codingCli: { enabledProviders: ['claude'] }, + }, + }, null, 2)) + }, + }, + }) + const info = await server.start() + + await page.goto(`${info.baseUrl}/?token=${info.token}&e2e=1`) + const harness = new TestHarness(page) + await harness.waitForHarness() + await harness.waitForConnection() + + // The boot tab shows the pane-type picker: pick Claude -> the picker + // asks for a starting directory -> a real claude-mode PTY spawning the + // fake CLI in that directory. + await page.getByRole('button', { name: /^Claude CLI$/i }).click({ timeout: 15_000 }) + const cwdBox = page.getByRole('combobox', { name: /starting directory for claude cli/i }) + await expect(cwdBox).toBeVisible({ timeout: 10_000 }) + await cwdBox.fill(sharedRoot) + await cwdBox.press('Enter') + await expect(page.locator('.xterm').first()).toBeVisible({ timeout: 30_000 }) + await harness.waitForTerminalText('fake-claude ready', 30_000) + + const claudeTabId = await harness.getActiveTabId() + expect(claudeTabId).toBeTruthy() + await expect.poll(async () => { + const layout = await harness.getPaneLayout(claudeTabId!) + return findTerminalLeaf(layout)?.content?.terminalId ?? null + }, { timeout: 20_000 }).not.toBeNull() + const layout = await harness.getPaneLayout(claudeTabId!) + const terminalId = findTerminalLeaf(layout)!.content.terminalId as string + + const claudeTab = page.locator(`[data-context="tab"][data-tab-id="${claudeTabId}"]`) + const claudeTabIcon = claudeTab.locator('svg').first() + + // Submit a prompt: PTY submit -> tracker busy -> BLUE. + await page.locator('.xterm').first().click() + await page.keyboard.type('hello fake claude') + await page.keyboard.press('Enter') + await expect(claudeTabIcon).toHaveClass(/text-blue-500/, { timeout: 10_000 }) + + // Move away so the claude tab is a background tab when the turn ends. + await page.getByRole('button', { name: 'New shell tab' }).click() + await harness.waitForTabCount(2) + const shellTabId = await harness.getActiveTabId() + expect(shellTabId).not.toBe(claudeTabId) + + // Turn end (BEL) clears busy well before the truly-idle edge: blue off. + await expect(claudeTabIcon).not.toHaveClass(/text-blue-500/, { timeout: FAKE_TURN_MS + 10_000 }) + + // After the quiet 2s grace the server broadcasts terminal.idle: exactly + // one alert edge lands (bell + shade pipeline), keyed to this terminal. + await expect.poll(async () => { + const state = await harness.getState() + return state?.turnCompletion?.lastIdleAtByTerminalId?.[terminalId] ?? null + }, { timeout: 20_000 }).not.toBeNull() + + const alerted = await harness.getState() + expect(alerted.turnCompletion.attentionByTab[claudeTabId!]).toBe(true) + expect(alerted.turnCompletion.seq).toBe(1) + + // SHADE: the background claude tab carries the attention highlight. + await expect(claudeTab).toHaveClass(/bg-emerald-100/, { timeout: 10_000 }) + + // One-shot: no further edges arrive during an extra settle window + // (no re-ring from replay, no second bell without a new turn). + await page.waitForTimeout(3_000) + const settled = await harness.getState() + expect(settled.turnCompletion.seq).toBe(1) + + // Activate the claude tab: the shade clears... + await claudeTab.click() + await expect.poll(async () => { + const state = await harness.getState() + return state?.turnCompletion?.attentionByTab?.[claudeTabId!] ?? null + }, { timeout: 10_000 }).toBeNull() + await expect(claudeTab).not.toHaveClass(/bg-emerald-100/) + + // ...while GREEN persists: the pane header shows the idle state (session + // known + not busy), independent of the shade's click-clearing. + await expect(page.getByRole('banner', { name: /^Pane:/ }).first()) + .toHaveClass(/bg-emerald-50/, { timeout: 10_000 }) + } finally { + await server?.stop().catch(() => {}) + await fs.rm(sharedRoot, { recursive: true, force: true }).catch(() => {}) + } + }) +}) From bdaf5c6f578f41615031f26f2dc19bcea21a13ea Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 24 Jul 2026 00:59:30 -0700 Subject: [PATCH 8/8] test(shared): align turn-complete gating spec with server-authoritative CLIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ€– Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- test/unit/shared/turn-complete-signal.test.ts | 26 +++++++------------ 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/test/unit/shared/turn-complete-signal.test.ts b/test/unit/shared/turn-complete-signal.test.ts index f4470b04a..b27699f45 100644 --- a/test/unit/shared/turn-complete-signal.test.ts +++ b/test/unit/shared/turn-complete-signal.test.ts @@ -102,30 +102,24 @@ describe('countTrackerTurnCompleteSignals', () => { describe('turn-complete side-effect gating', () => { it('allows client-minted live turn completion only for non-server-authoritative modes', () => { - expect(shouldAllowTerminalOutputSideEffect({ - source: 'live', - effect: 'turn_complete', - mode: 'opencode', - })).toBe(true) + // Truly-idle alerting: all four terminal CLIs are server-authoritative + // (terminal.idle broadcast) β€” the client must not mint completions for them. + for (const mode of ['claude', 'codex', 'opencode', 'amplifier'] as const) { + expect(shouldAllowTerminalOutputSideEffect({ + source: 'live', + effect: 'turn_complete', + mode, + })).toBe(false) + } expect(shouldAllowTerminalOutputSideEffect({ source: 'live', effect: 'turn_complete', mode: 'shell', })).toBe(true) - expect(shouldAllowTerminalOutputSideEffect({ - source: 'live', - effect: 'turn_complete', - mode: 'claude', - })).toBe(false) - expect(shouldAllowTerminalOutputSideEffect({ - source: 'live', - effect: 'turn_complete', - mode: 'codex', - })).toBe(false) expect(shouldAllowTerminalOutputSideEffect({ source: 'replay', effect: 'turn_complete', - mode: 'opencode', + mode: 'shell', })).toBe(false) }) })