mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 16:02:43 +00:00
fix(runtime): rank tui-idle evidence instead of inferring idle from silence
`terminal wait --for tui-idle` returned satisfied in ~0s while an agent was mid-turn. The shared title detector defaults a name-only agent title to `idle` so the sidebar can clear a stale spinner, and the wait accepted that stored value as completion. Rank the evidence instead. An explicit idle marker in the agent's own title or a known ready prompt settles the wait; a fresh first-party OSC 9999 status saying working/blocked/waiting vetoes it; a name-only title is a last resort that settles only once the stream has also gone quiet. The rank is derived at read time from `lastOscTitle` rather than stamped onto the record, because `syncWindowGraph` rebuilds leaves from an explicit field list and would drop a bespoke provenance field on any renderer publish. Two things the ranking alone gets wrong are handled here too. A quiet non-shell foreground process no longer proves idle on a pane where Orca launched a known agent — that is an agent still booting, and resolving on it is what let `dispatch --inject` lose the prompt (#9976). And the idle poll re-reads the live leaf each tick, because a record captured at registration stops advancing and its frozen `lastOutputAt` makes the quiescence gate pass while the pane streams. The demotion is scoped to agents that go on to announce rest explicitly. Grok, Copilot, Aider, Mimo, agy and OpenCode emit their name and nothing more at rest: a real idle Grok pane repaints its banner about four times a second forever, so demanding quiescence from it left no settle signal at all and the wait ran to timeout. The design is Brennan Benson's, from #14642, which won a cross-review against #6012, #6555 and this branch's earlier approach; it is ported here only because that branch shares no git history with main and cannot be merged. Neil's #6555 first drew the explicit-vs-ambiguous line the ranking rests on, and Revofusion's #6012 first identified that a single title sample cannot prove completion. Fixes #6011 Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Revofusion <syed@moonai.org>
This commit is contained in:
co-authored by
Brennan Benson
Neil
Revofusion
parent
e86cba888b
commit
34757358fe
@@ -58,7 +58,15 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper
|
||||
this.setPtyManagementTitleFromObservedTitle(pty, normalizedTitle, observedAt)
|
||||
}
|
||||
ptyRecordChanged = prevTitle !== recordedTitle || prevStatus !== agentStatus
|
||||
if (agentStatus === 'idle' && prevStatus !== 'idle') {
|
||||
// Why `!== 'permission'` rather than `!== 'idle'`: a name-only idle leaves the waiter
|
||||
// parked on its poll, so the later explicit idle is an idle→idle step that still has
|
||||
// to be offered. The resolve helper re-ranks and returns early when it is not yet
|
||||
// satisfying evidence, which is what the old edge guard was really protecting.
|
||||
// Why also gated on a change: re-ranking an unchanged idle title cannot reach a
|
||||
// different verdict. Tier 1 and 2 depend only on the title and the status; tier 3
|
||||
// needs the stream to go quiet, which cannot happen on the frame that just wrote to
|
||||
// it. Repainted frames would otherwise re-scan the pane tail for nothing.
|
||||
if (agentStatus === 'idle' && prevStatus !== 'permission' && ptyRecordChanged) {
|
||||
this.resolvePtyTuiIdleWaiters(pty, ptyId)
|
||||
}
|
||||
const shouldDelayMobileSnapshot =
|
||||
@@ -95,6 +103,7 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper
|
||||
// the shell took over the title — the stuck-spinner bug in #1437.
|
||||
const prevStatus = leaf.lastAgentStatus
|
||||
const prevObservedLive = leaf.lastAgentStatusObservedLive
|
||||
const prevLeafTitle = leaf.lastOscTitle
|
||||
leaf.lastOscTitle = recordedTitle
|
||||
leaf.lastOscTitleAt = identityOnlyTitle ? null : this.nextTitleObservationSequence()
|
||||
// Why: when a new OSC title doesn't classify as an agent state (e.g.
|
||||
@@ -112,7 +121,15 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper
|
||||
// working→idle transition that never comes. Permission→idle is excluded:
|
||||
// it means the agent was blocked on user approval and the user said no,
|
||||
// which isn't a task-completion signal.
|
||||
if (agentStatus === 'idle' && prevStatus !== 'idle') {
|
||||
// Why not `prevStatus !== 'idle'`: see the pty branch — the resolve helper re-ranks,
|
||||
// so an idle→idle step that upgrades weak evidence to explicit must still be offered.
|
||||
// Why the change gate: see the pty branch — an unchanged idle title re-ranks to the
|
||||
// same verdict, so repainted frames must not re-scan the tail.
|
||||
if (
|
||||
agentStatus === 'idle' &&
|
||||
prevStatus !== 'permission' &&
|
||||
(prevStatus !== agentStatus || prevLeafTitle !== recordedTitle)
|
||||
) {
|
||||
this.resolveTuiIdleWaiters(leaf)
|
||||
}
|
||||
// Why the second condition: push delivery is gated on LIVE idle, so its
|
||||
@@ -155,6 +172,9 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper
|
||||
pty.lastOscTitleAt = null
|
||||
pty.lastOscTitleEpochMs = null
|
||||
pty.lastAgentStatus = null
|
||||
// Why: the prior process's first-party status would otherwise veto idle for its
|
||||
// replacement — a stale `working` keeps tui-idle unresolved on the new generation.
|
||||
pty.lastExplicitAgentStatus = null
|
||||
// Why: the prior process's live frames say nothing about the replacement,
|
||||
// so the seed a same-id restore applies must not inherit its authority.
|
||||
pty.lastAgentStatusObservedLive = false
|
||||
|
||||
@@ -215,6 +215,19 @@ export class OrcaRuntimeWithOnPtyData extends OrcaRuntimeWithPreparePtyExecution
|
||||
for (const payload of agentStatusChunk.payloads) {
|
||||
titleTrackerEntry.pendingFacts.push({ kind: 'agent-status', payload })
|
||||
}
|
||||
// Why on the PTY record: the retained status snapshots are keyed by paneKey, which a
|
||||
// background CLI-created PTY may never have. `terminal wait --for tui-idle` still needs
|
||||
// the agent's own account of itself, and ptyId is the only identity that path always holds.
|
||||
const latestAgentStatus = agentStatusChunk.payloads.at(-1)
|
||||
if (latestAgentStatus) {
|
||||
const ptyRecord = this.ptysById.get(ptyId)
|
||||
if (ptyRecord) {
|
||||
ptyRecord.lastExplicitAgentStatus = {
|
||||
state: latestAgentStatus.state,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
}
|
||||
}
|
||||
titleTrackerEntry.tracker.handleChunk(agentStatusChunk.cleanData, {
|
||||
titleScanData: titleInput
|
||||
})
|
||||
|
||||
@@ -4,7 +4,13 @@ import { OrcaRuntimeWithBindPtyIncarnationHandle } from './orca-runtime-bind-pty
|
||||
import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
|
||||
import { buildPtyTerminalWaitResult, buildTerminalWaitResult } from './terminal-wait-results'
|
||||
import type { AgentStatus } from '../../shared/agent-detection'
|
||||
import { detectExplicitIdleStatusFromTitle } from './terminal-wait-detection'
|
||||
import {
|
||||
detectExplicitIdleStatusFromTitle,
|
||||
isKnownReadyPromptPreview
|
||||
} from './terminal-wait-detection'
|
||||
import { buildTerminalWaitText } from './terminal-wait-tail-state'
|
||||
import { isTuiIdleSatisfied } from './tui-idle-evidence'
|
||||
import { TUI_IDLE_QUIESCENCE_MS } from './orca-runtime-postlude'
|
||||
|
||||
export class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithBindPtyIncarnationHandle {
|
||||
protected resolveExitWaiters(leaf: RuntimeLeafRecord): void {
|
||||
@@ -43,6 +49,12 @@ export class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithBindPtyInc
|
||||
if (!waiters || waiters.size === 0) {
|
||||
return
|
||||
}
|
||||
// Why re-rank rather than resolve outright: the transition that brought us here is
|
||||
// only a title sample, and a name-only title arriving mid-turn is the weakest tier
|
||||
// there is (#6011). Leave such a waiter on its poll to be corroborated instead.
|
||||
if (!this.isTuiIdleSatisfiedForLeaf(leaf)) {
|
||||
return
|
||||
}
|
||||
for (const waiter of [...waiters]) {
|
||||
if (waiter.condition === 'tui-idle') {
|
||||
this.resolveWaiter(waiter, buildTerminalWaitResult(handle, 'tui-idle', leaf))
|
||||
@@ -78,6 +90,10 @@ export class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithBindPtyInc
|
||||
if (!waiters || waiters.size === 0) {
|
||||
return
|
||||
}
|
||||
// Why: same re-ranking as resolveTuiIdleWaiters above.
|
||||
if (!this.isTuiIdleSatisfiedForPty(pty)) {
|
||||
return
|
||||
}
|
||||
for (const waiter of [...waiters]) {
|
||||
if (waiter.condition === 'tui-idle') {
|
||||
this.resolveWaiter(waiter, buildPtyTerminalWaitResult(handle, 'tui-idle', pty))
|
||||
@@ -86,6 +102,35 @@ export class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithBindPtyInc
|
||||
}
|
||||
|
||||
// Why: the primary OSC-title signal can't fire for daemon-hosted terminals (no PTY data through the runtime), so this fallback polls the renderer-synced tab title + foreground-process quiescence; self-cancels when the OSC path fires.
|
||||
protected isTuiIdleSatisfiedForLeaf(leaf: RuntimeLeafRecord): boolean {
|
||||
return isTuiIdleSatisfied({
|
||||
record: leaf,
|
||||
rendererTitle: leaf.paneTitle ?? this.tabs.get(leaf.tabId)?.title ?? null,
|
||||
readPositiveBodyEvidence: () =>
|
||||
isKnownReadyPromptPreview(
|
||||
buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview)
|
||||
),
|
||||
agent: this.getPaneAgentForTuiIdle(leaf.ptyId),
|
||||
firstPartyStatus:
|
||||
(leaf.ptyId ? this.ptysById.get(leaf.ptyId)?.lastExplicitAgentStatus : null) ?? null,
|
||||
quiescenceMs: TUI_IDLE_QUIESCENCE_MS
|
||||
})
|
||||
}
|
||||
|
||||
protected isTuiIdleSatisfiedForPty(pty: RuntimePtyWorktreeRecord): boolean {
|
||||
return isTuiIdleSatisfied({
|
||||
record: pty,
|
||||
readPositiveBodyEvidence: () =>
|
||||
this.getAdoptedPtyExplicitIdleStatus(pty) === 'idle' ||
|
||||
isKnownReadyPromptPreview(
|
||||
buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview)
|
||||
),
|
||||
agent: this.getPaneAgentForTuiIdle(pty.ptyId),
|
||||
firstPartyStatus: pty.lastExplicitAgentStatus ?? null,
|
||||
quiescenceMs: TUI_IDLE_QUIESCENCE_MS
|
||||
})
|
||||
}
|
||||
|
||||
protected getAdoptedPtyExplicitIdleStatus(pty: RuntimePtyWorktreeRecord): AgentStatus | null {
|
||||
const title = this.getAdoptedPtyTitle(pty)
|
||||
return title ? detectExplicitIdleStatusFromTitle(title) : null
|
||||
|
||||
@@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto'
|
||||
import { preserveTerminalRetirementProofs } from './mobile-session-terminal-retirement-proof'
|
||||
import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
|
||||
import { replaceConversationInSnapshot } from './structured-conversation-tab-replacement'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import type { RuntimeStore } from './runtime-store-contract'
|
||||
import type { RuntimeClientSettingsController } from './runtime-client-settings'
|
||||
import type { RuntimeAutomationController } from './runtime-automation-controller'
|
||||
@@ -263,6 +264,16 @@ export class OrcaRuntimeWithRuntimeId {
|
||||
protected pendingMobileSessionPtyAggregateInventoryRefresh: Promise<PtyControllerInventory | null> | null =
|
||||
null
|
||||
|
||||
/** The agent Orca believes owns this pane, for tui-idle evidence ranking. Launch
|
||||
* authority first; the live foreground agent covers panes Orca did not launch. */
|
||||
protected getPaneAgentForTuiIdle(ptyId: string | null | undefined): TuiAgent | null {
|
||||
if (!ptyId) {
|
||||
return null
|
||||
}
|
||||
const pty = this.ptysById.get(ptyId)
|
||||
return pty?.launchAgent ?? pty?.foregroundAgent ?? null
|
||||
}
|
||||
|
||||
protected leaves = new Map<string, RuntimeLeafRecord>()
|
||||
|
||||
// Why: PTY output is a per-keystroke hot path. Looking up affected leaves by
|
||||
@@ -317,6 +328,10 @@ export class OrcaRuntimeWithRuntimeId {
|
||||
getTabTitle: (tabId) => this.tabs.get(tabId)?.title ?? null,
|
||||
getForegroundProcess: (ptyId) => this.ptyController?.getForegroundProcess(ptyId) ?? null,
|
||||
getAdoptedPtyIdleStatus: (pty) => this.getAdoptedPtyExplicitIdleStatus(pty),
|
||||
getPaneAgent: (ptyId) => this.getPaneAgentForTuiIdle(ptyId),
|
||||
getFirstPartyAgentStatus: (ptyId) =>
|
||||
(ptyId ? this.ptysById.get(ptyId)?.lastExplicitAgentStatus : null) ?? null,
|
||||
getLiveLeaf: (leaf) => this.leaves.get(this.getLeafKey(leaf.tabId, leaf.leafId)) ?? leaf,
|
||||
resolve: (waiter, result) => this.terminalWaiters.resolve(waiter, result)
|
||||
})
|
||||
|
||||
@@ -327,6 +342,10 @@ export class OrcaRuntimeWithRuntimeId {
|
||||
getLiveLeaf: (handle) => this.getLiveLeafForHandle(handle),
|
||||
getAdoptedPtyIdleStatus: (pty) => this.getAdoptedPtyExplicitIdleStatus(pty),
|
||||
getTabTitle: (tabId) => this.tabs.get(tabId)?.title ?? null,
|
||||
quiescenceMs: TUI_IDLE_QUIESCENCE_MS,
|
||||
getPaneAgent: (ptyId) => this.getPaneAgentForTuiIdle(ptyId),
|
||||
getFirstPartyAgentStatus: (ptyId) =>
|
||||
(ptyId ? this.ptysById.get(ptyId)?.lastExplicitAgentStatus : null) ?? null,
|
||||
startVisibleReadProbe: (waiter, waiterTimeoutMs) =>
|
||||
this.startTuiIdleVisibleReadProbe(waiter, waiterTimeoutMs)
|
||||
},
|
||||
|
||||
@@ -71,6 +71,9 @@ describe('RuntimeTerminalIdlePolls timer budget', () => {
|
||||
getTabTitle: () => null,
|
||||
getForegroundProcess: () => null,
|
||||
getAdoptedPtyIdleStatus: () => null,
|
||||
getPaneAgent: () => null,
|
||||
getFirstPartyAgentStatus: () => null,
|
||||
getLiveLeaf: (leaf) => leaf,
|
||||
resolve: (waiter, result) => resolved.push({ handle: waiter.handle, result })
|
||||
})
|
||||
|
||||
@@ -103,6 +106,9 @@ describe('RuntimeTerminalIdlePolls timer budget', () => {
|
||||
getTabTitle: () => null,
|
||||
getForegroundProcess: () => null,
|
||||
getAdoptedPtyIdleStatus: () => null,
|
||||
getPaneAgent: () => null,
|
||||
getFirstPartyAgentStatus: () => null,
|
||||
getLiveLeaf: (leaf) => leaf,
|
||||
resolve: () => {}
|
||||
})
|
||||
|
||||
@@ -125,6 +131,9 @@ describe('RuntimeTerminalIdlePolls timer budget', () => {
|
||||
getTabTitle: () => null,
|
||||
getForegroundProcess: () => null,
|
||||
getAdoptedPtyIdleStatus: () => null,
|
||||
getPaneAgent: () => null,
|
||||
getFirstPartyAgentStatus: () => null,
|
||||
getLiveLeaf: (leaf) => leaf,
|
||||
resolve: () => {}
|
||||
})
|
||||
const first = makeWaiter('a')
|
||||
@@ -152,6 +161,9 @@ describe('RuntimeTerminalIdlePolls timer budget', () => {
|
||||
gates.push(resolve)
|
||||
}),
|
||||
getAdoptedPtyIdleStatus: () => null,
|
||||
getPaneAgent: () => null,
|
||||
getFirstPartyAgentStatus: () => null,
|
||||
getLiveLeaf: (leaf) => leaf,
|
||||
resolve: (waiter) => resolved.push(waiter.handle)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { isShellProcess, type AgentStatus } from '../../shared/agent-detection'
|
||||
import type { RuntimeTerminalWait } from '../../shared/runtime-types'
|
||||
import {
|
||||
detectExplicitIdleStatusFromTitle,
|
||||
detectTerminalWaitBlockedReason,
|
||||
isKnownReadyPromptPreview
|
||||
} from './terminal-wait-detection'
|
||||
@@ -12,6 +11,12 @@ import {
|
||||
buildTerminalWaitResult
|
||||
} from './terminal-wait-results'
|
||||
import { buildTerminalWaitText } from './terminal-wait-tail-state'
|
||||
import {
|
||||
isTuiIdleSatisfied,
|
||||
quietForegroundProcessProvesTuiIdle,
|
||||
type FirstPartyAgentStatus
|
||||
} from './tui-idle-evidence'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import type { TerminalWaiter } from './runtime-terminal-contracts'
|
||||
import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
|
||||
|
||||
@@ -21,6 +26,10 @@ type RuntimeTerminalIdlePollDependencies = {
|
||||
getTabTitle(tabId: string): string | null
|
||||
getForegroundProcess(ptyId: string): Promise<string | null> | null
|
||||
getAdoptedPtyIdleStatus(pty: RuntimePtyWorktreeRecord): AgentStatus | null
|
||||
getPaneAgent(ptyId: string | null | undefined): TuiAgent | null
|
||||
getFirstPartyAgentStatus(ptyId: string | null | undefined): FirstPartyAgentStatus
|
||||
/** Re-read the record the waiter registered against; see `liveLeaf` below. */
|
||||
getLiveLeaf(leaf: RuntimeLeafRecord): RuntimeLeafRecord
|
||||
resolve(waiter: TerminalWaiter, result: RuntimeTerminalWait): void
|
||||
}
|
||||
|
||||
@@ -82,20 +91,15 @@ export class RuntimeTerminalIdlePolls {
|
||||
if (!this.entries.has(entry)) {
|
||||
return
|
||||
}
|
||||
const { waiter, leaf } = entry
|
||||
const { waiter } = entry
|
||||
// Why re-read: `syncWindowGraph` rebuilds `this.leaves` with fresh objects on every
|
||||
// renderer publish, so the record captured at registration stops advancing. Its
|
||||
// `lastOutputAt` freezes, the quiescence gate below then reads an ever-growing
|
||||
// elapsed time, and the waiter settles while the pane is in fact still streaming.
|
||||
const leaf = this.deps.getLiveLeaf(entry.leaf)
|
||||
const agent = this.deps.getPaneAgent(leaf.ptyId)
|
||||
let startedForegroundPoll = false
|
||||
try {
|
||||
if (leaf.lastAgentStatus === 'idle') {
|
||||
this.stop(entry)
|
||||
this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf))
|
||||
return
|
||||
}
|
||||
const title = leaf.paneTitle ?? this.deps.getTabTitle(leaf.tabId)
|
||||
if (title && detectExplicitIdleStatusFromTitle(title) === 'idle') {
|
||||
this.stop(entry)
|
||||
this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf))
|
||||
return
|
||||
}
|
||||
const waitText = buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview)
|
||||
const blockedReason = detectTerminalWaitBlockedReason(waitText)
|
||||
if (blockedReason) {
|
||||
@@ -106,12 +110,26 @@ export class RuntimeTerminalIdlePolls {
|
||||
)
|
||||
return
|
||||
}
|
||||
if (isKnownReadyPromptPreview(waitText)) {
|
||||
if (
|
||||
isTuiIdleSatisfied({
|
||||
record: leaf,
|
||||
rendererTitle: leaf.paneTitle ?? this.deps.getTabTitle(leaf.tabId),
|
||||
readPositiveBodyEvidence: () => isKnownReadyPromptPreview(waitText),
|
||||
agent,
|
||||
firstPartyStatus: this.deps.getFirstPartyAgentStatus(leaf.ptyId),
|
||||
quiescenceMs: this.deps.quiescenceMs
|
||||
})
|
||||
) {
|
||||
this.stop(entry)
|
||||
this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf))
|
||||
return
|
||||
}
|
||||
if (leaf.lastAgentStatus === null && leaf.ptyId && !entry.foregroundPollInFlight) {
|
||||
if (
|
||||
leaf.lastAgentStatus === null &&
|
||||
quietForegroundProcessProvesTuiIdle(agent) &&
|
||||
leaf.ptyId &&
|
||||
!entry.foregroundPollInFlight
|
||||
) {
|
||||
const foregroundRead = this.deps.getForegroundProcess(leaf.ptyId)
|
||||
if (!foregroundRead) {
|
||||
return
|
||||
@@ -119,13 +137,14 @@ export class RuntimeTerminalIdlePolls {
|
||||
entry.foregroundPollInFlight = true
|
||||
startedForegroundPoll = true
|
||||
const foreground = await foregroundRead
|
||||
const live = this.deps.getLiveLeaf(entry.leaf)
|
||||
if (
|
||||
foreground &&
|
||||
!isShellProcess(foreground) &&
|
||||
(leaf.lastOutputAt ? Date.now() - leaf.lastOutputAt : 0) >= this.deps.quiescenceMs
|
||||
(live.lastOutputAt ? Date.now() - live.lastOutputAt : 0) >= this.deps.quiescenceMs
|
||||
) {
|
||||
this.stop(entry)
|
||||
this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf))
|
||||
this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', live))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -142,13 +161,11 @@ export class RuntimeTerminalIdlePolls {
|
||||
return
|
||||
}
|
||||
const { waiter, pty } = entry
|
||||
// Why no re-read here: `ptysById` has a single create-once `set` site, so PTY
|
||||
// records are mutated in place rather than swapped, and a capture stays live.
|
||||
const agent = this.deps.getPaneAgent(pty.ptyId)
|
||||
let startedForegroundPoll = false
|
||||
try {
|
||||
if (pty.lastAgentStatus === 'idle') {
|
||||
this.stop(entry)
|
||||
this.deps.resolve(waiter, buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty))
|
||||
return
|
||||
}
|
||||
const waitText = buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview)
|
||||
const blockedReason = detectTerminalWaitBlockedReason(waitText)
|
||||
if (blockedReason) {
|
||||
@@ -160,14 +177,25 @@ export class RuntimeTerminalIdlePolls {
|
||||
return
|
||||
}
|
||||
if (
|
||||
this.deps.getAdoptedPtyIdleStatus(pty) === 'idle' ||
|
||||
isKnownReadyPromptPreview(waitText)
|
||||
isTuiIdleSatisfied({
|
||||
record: pty,
|
||||
readPositiveBodyEvidence: () =>
|
||||
this.deps.getAdoptedPtyIdleStatus(pty) === 'idle' ||
|
||||
isKnownReadyPromptPreview(waitText),
|
||||
agent,
|
||||
firstPartyStatus: this.deps.getFirstPartyAgentStatus(pty.ptyId),
|
||||
quiescenceMs: this.deps.quiescenceMs
|
||||
})
|
||||
) {
|
||||
this.stop(entry)
|
||||
this.deps.resolve(waiter, buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty))
|
||||
return
|
||||
}
|
||||
if (pty.lastAgentStatus === null && !entry.foregroundPollInFlight) {
|
||||
if (
|
||||
pty.lastAgentStatus === null &&
|
||||
quietForegroundProcessProvesTuiIdle(agent) &&
|
||||
!entry.foregroundPollInFlight
|
||||
) {
|
||||
const foregroundRead = this.deps.getForegroundProcess(pty.ptyId)
|
||||
if (!foregroundRead) {
|
||||
return
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AgentStatus } from '../../shared/agent-detection'
|
||||
import type { AgentStatusState } from '../../shared/agent-status-types'
|
||||
import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume'
|
||||
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
|
||||
import type { RuntimeSyncedLeaf } from '../../shared/runtime-types'
|
||||
@@ -65,6 +66,10 @@ export type RuntimePtyWorktreeRecord = RuntimeTerminalTailState & {
|
||||
lastExitCause: TerminalExitCause | null
|
||||
lastAgentStatus: AgentStatus | null
|
||||
lastAgentStatusObservedLive: boolean
|
||||
/** Latest first-party state from the agent's own OSC 9999 status stream — what the
|
||||
* agent SAYS it is doing, as opposed to `lastAgentStatus`, which is inferred from its
|
||||
* OSC title. Optional: absent until a payload lands. */
|
||||
lastExplicitAgentStatus?: { state: AgentStatusState; updatedAt: number } | null
|
||||
lastAgentStatusStartedAtEpochMs: number | null
|
||||
lastAgentStatusRichInvalidatedAtEpochMs: number | null
|
||||
lastOscTitle: string | null
|
||||
|
||||
@@ -3,7 +3,6 @@ import type {
|
||||
RuntimeTerminalWaitCondition
|
||||
} from '../../shared/runtime-types'
|
||||
import {
|
||||
detectExplicitIdleStatusFromTitle,
|
||||
detectTerminalWaitBlockedReason,
|
||||
isKnownReadyPromptPreview
|
||||
} from './terminal-wait-detection'
|
||||
@@ -15,6 +14,8 @@ import {
|
||||
getTerminalState
|
||||
} from './terminal-wait-results'
|
||||
import { buildTerminalWaitText } from './terminal-wait-tail-state'
|
||||
import { isTuiIdleSatisfied, type FirstPartyAgentStatus } from './tui-idle-evidence'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import type { TerminalWaiter } from './runtime-terminal-contracts'
|
||||
import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
|
||||
import type { AgentStatus } from '../../shared/agent-detection'
|
||||
@@ -27,6 +28,9 @@ type RuntimeTerminalWaitDependencies = {
|
||||
getLiveLeaf(handle: string): { leaf: RuntimeLeafRecord }
|
||||
getAdoptedPtyIdleStatus(pty: RuntimePtyWorktreeRecord): AgentStatus | null
|
||||
getTabTitle(tabId: string): string | null
|
||||
quiescenceMs: number
|
||||
getPaneAgent(ptyId: string | null | undefined): TuiAgent | null
|
||||
getFirstPartyAgentStatus(ptyId: string | null | undefined): FirstPartyAgentStatus
|
||||
startVisibleReadProbe(waiter: TerminalWaiter, waiterTimeoutMs: number): void
|
||||
}
|
||||
|
||||
@@ -37,6 +41,30 @@ export class RuntimeTerminalWait {
|
||||
private readonly polls: RuntimeTerminalIdlePolls
|
||||
) {}
|
||||
|
||||
/** Why one helper per record kind: every satisfaction site must rank the same way,
|
||||
* or the immediate check and the poll disagree about the same pane. */
|
||||
private ptySatisfied(pty: RuntimePtyWorktreeRecord, waitText: string): boolean {
|
||||
return isTuiIdleSatisfied({
|
||||
record: pty,
|
||||
readPositiveBodyEvidence: () =>
|
||||
this.deps.getAdoptedPtyIdleStatus(pty) === 'idle' || isKnownReadyPromptPreview(waitText),
|
||||
agent: this.deps.getPaneAgent(pty.ptyId),
|
||||
firstPartyStatus: this.deps.getFirstPartyAgentStatus(pty.ptyId),
|
||||
quiescenceMs: this.deps.quiescenceMs
|
||||
})
|
||||
}
|
||||
|
||||
private leafSatisfied(leaf: RuntimeLeafRecord, waitText: string): boolean {
|
||||
return isTuiIdleSatisfied({
|
||||
record: leaf,
|
||||
rendererTitle: leaf.paneTitle ?? this.deps.getTabTitle(leaf.tabId),
|
||||
readPositiveBodyEvidence: () => isKnownReadyPromptPreview(waitText),
|
||||
agent: this.deps.getPaneAgent(leaf.ptyId),
|
||||
firstPartyStatus: this.deps.getFirstPartyAgentStatus(leaf.ptyId),
|
||||
quiescenceMs: this.deps.quiescenceMs
|
||||
})
|
||||
}
|
||||
|
||||
async wait(
|
||||
handle: string,
|
||||
options?: {
|
||||
@@ -60,14 +88,7 @@ export class RuntimeTerminalWait {
|
||||
if (condition === 'tui-idle' && ptyBlockedReason) {
|
||||
return buildPtyTerminalWaitBlockedResult(handle, condition, pty.pty, ptyBlockedReason)
|
||||
}
|
||||
if (condition === 'tui-idle' && pty.pty.lastAgentStatus === 'idle') {
|
||||
return buildPtyTerminalWaitResult(handle, condition, pty.pty)
|
||||
}
|
||||
if (
|
||||
condition === 'tui-idle' &&
|
||||
(this.deps.getAdoptedPtyIdleStatus(pty.pty) === 'idle' ||
|
||||
isKnownReadyPromptPreview(ptyWaitText))
|
||||
) {
|
||||
if (condition === 'tui-idle' && this.ptySatisfied(pty.pty, ptyWaitText)) {
|
||||
return buildPtyTerminalWaitResult(handle, condition, pty.pty)
|
||||
}
|
||||
return await new Promise<RuntimeTerminalWaitResult>((resolve, reject) => {
|
||||
@@ -115,12 +136,7 @@ export class RuntimeTerminalWait {
|
||||
waiter,
|
||||
buildPtyTerminalWaitBlockedResult(handle, condition, live.pty, blockedReason)
|
||||
)
|
||||
} else if (live.pty.lastAgentStatus === 'idle') {
|
||||
this.waiters.resolve(waiter, buildPtyTerminalWaitResult(handle, condition, live.pty))
|
||||
} else if (
|
||||
this.deps.getAdoptedPtyIdleStatus(live.pty) === 'idle' ||
|
||||
isKnownReadyPromptPreview(livePtyWaitText)
|
||||
) {
|
||||
} else if (this.ptySatisfied(live.pty, livePtyWaitText)) {
|
||||
this.waiters.resolve(waiter, buildPtyTerminalWaitResult(handle, condition, live.pty))
|
||||
} else {
|
||||
this.polls.startPty(waiter, live.pty)
|
||||
@@ -147,18 +163,9 @@ export class RuntimeTerminalWait {
|
||||
// detection that powers the renderer's "Task complete" notifications.
|
||||
// Why: only 'idle' satisfies tui-idle, not 'permission'. Permission means the
|
||||
// agent is blocked on user approval, not finished with its task.
|
||||
if (condition === 'tui-idle' && leaf.lastAgentStatus === 'idle') {
|
||||
if (condition === 'tui-idle' && this.leafSatisfied(leaf, leafWaitText)) {
|
||||
return buildTerminalWaitResult(handle, condition, leaf)
|
||||
}
|
||||
if (condition === 'tui-idle') {
|
||||
const fastPathTitle = leaf.paneTitle ?? this.deps.getTabTitle(leaf.tabId)
|
||||
if (
|
||||
(fastPathTitle && detectExplicitIdleStatusFromTitle(fastPathTitle) === 'idle') ||
|
||||
isKnownReadyPromptPreview(leafWaitText)
|
||||
) {
|
||||
return buildTerminalWaitResult(handle, condition, leaf)
|
||||
}
|
||||
}
|
||||
|
||||
return await new Promise<RuntimeTerminalWaitResult>((resolve, reject) => {
|
||||
// Why: tui-idle depends on OSC title transitions from a recognized agent.
|
||||
@@ -214,7 +221,7 @@ export class RuntimeTerminalWait {
|
||||
waiter,
|
||||
buildTerminalWaitBlockedResult(handle, condition, live.leaf, blockedReason)
|
||||
)
|
||||
} else if (live.leaf.lastAgentStatus === 'idle') {
|
||||
} else if (this.leafSatisfied(live.leaf, liveLeafWaitText)) {
|
||||
// Why: don't clear lastAgentStatus here. It's a factual record of the
|
||||
// last detected OSC state, not a one-shot signal. Clearing it causes
|
||||
// subsequent tui-idle waiters to hang even though the agent is idle —
|
||||
@@ -224,17 +231,9 @@ export class RuntimeTerminalWait {
|
||||
// Why: renderer-synced previews can show a known ready prompt even
|
||||
// while the last OSC title is still "working"; keep polling the
|
||||
// preview/title until the waiter resolves or hits its timeout.
|
||||
const fastPathTitle = live.leaf.paneTitle ?? this.deps.getTabTitle(live.leaf.tabId)
|
||||
if (
|
||||
(fastPathTitle && detectExplicitIdleStatusFromTitle(fastPathTitle) === 'idle') ||
|
||||
isKnownReadyPromptPreview(liveLeafWaitText)
|
||||
) {
|
||||
this.waiters.resolve(waiter, buildTerminalWaitResult(handle, condition, live.leaf))
|
||||
} else {
|
||||
this.polls.startLeaf(waiter, live.leaf)
|
||||
if (live.leaf.lastAgentStatus === null && liveLeafWaitText.length === 0) {
|
||||
this.deps.startVisibleReadProbe(waiter, effectiveTimeoutMs)
|
||||
}
|
||||
this.polls.startLeaf(waiter, live.leaf)
|
||||
if (live.leaf.lastAgentStatus === null && liveLeafWaitText.length === 0) {
|
||||
this.deps.startVisibleReadProbe(waiter, effectiveTimeoutMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memoizeTitleClassification } from '../../shared/terminal-title-classification-memo'
|
||||
import {
|
||||
detectAgentStatusFromTitle,
|
||||
isOpenCodeNativeTitle,
|
||||
@@ -15,7 +16,7 @@ const CLAUDE_IDLE_PREFIX = '\u2733'
|
||||
const GEMINI_IDLE_PREFIX = '\u25c7'
|
||||
const PI_IDLE_PREFIX = '\u03c0 - '
|
||||
|
||||
export function detectExplicitIdleStatusFromTitle(title: string): AgentStatus | null {
|
||||
function computeExplicitIdleStatusFromTitle(title: string): AgentStatus | null {
|
||||
const status = detectAgentStatusFromTitle(title)
|
||||
if (status !== 'idle') {
|
||||
return null
|
||||
@@ -35,6 +36,14 @@ export function detectExplicitIdleStatusFromTitle(title: string): AgentStatus |
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure in `title`, so it is memoized on the title string like the status classifier it
|
||||
* wraps: the wait path re-asks for the same unchanged title on every poll tick and every
|
||||
* repaint frame, and the marker scan below is a regex sweep each time (~72ns vs ~7ns).
|
||||
*/
|
||||
export const detectExplicitIdleStatusFromTitle: (title: string) => AgentStatus | null =
|
||||
memoizeTitleClassification(computeExplicitIdleStatusFromTitle)
|
||||
|
||||
export function isKnownReadyPromptPreview(preview: string): boolean {
|
||||
const normalized = preview.toLowerCase()
|
||||
const readyIndex = findKnownReadyPromptIndex(normalized)
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RuntimeTerminalIdlePolls } from './runtime-terminal-idle-polls'
|
||||
import { RuntimeTerminalWait } from './runtime-terminal-wait'
|
||||
import { RuntimeTerminalWaiterRegistry } from './runtime-terminal-waiter-registry'
|
||||
import {
|
||||
errorMessage,
|
||||
makeTuiIdleLeaf,
|
||||
makeTuiIdlePty,
|
||||
makeTuiIdleRuntime
|
||||
} from './tui-idle-wait-test-harness'
|
||||
import type { RuntimeSyncWindowGraph } from '../../shared/runtime-types'
|
||||
import type { AgentStatus } from '../../shared/agent-detection'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
|
||||
import type { FirstPartyAgentStatus } from './tui-idle-evidence'
|
||||
|
||||
// #6011: `terminal wait --for tui-idle` returned satisfied in ~0s against a working agent,
|
||||
// because a Codex/Devin OSC title that carries only the agent NAME is stored as `idle` and
|
||||
// the wait accepted the stored value. These tests pin which evidence settles the wait,
|
||||
// which only corroborates, and which vetoes.
|
||||
|
||||
const POLL_INTERVAL_MS = 2000
|
||||
const QUIESCENCE_MS = 3000
|
||||
const NAME_ONLY_TITLE = 'Codex'
|
||||
const EXPLICIT_IDLE_TITLE = 'Codex ready'
|
||||
const HANDLE = 'terminal-1'
|
||||
|
||||
function createWait(options: {
|
||||
pty?: RuntimePtyWorktreeRecord
|
||||
leaf?: RuntimeLeafRecord
|
||||
adoptedIdleStatus?: AgentStatus | null
|
||||
tabTitle?: string | null
|
||||
foreground?: string | null
|
||||
agent?: TuiAgent | null
|
||||
firstPartyStatus?: FirstPartyAgentStatus
|
||||
liveLeaf?: () => RuntimeLeafRecord
|
||||
}) {
|
||||
const waiters = new RuntimeTerminalWaiterRegistry()
|
||||
const startVisibleReadProbe = vi.fn()
|
||||
const shared = {
|
||||
getTabTitle: () => options.tabTitle ?? null,
|
||||
getAdoptedPtyIdleStatus: () => options.adoptedIdleStatus ?? null,
|
||||
getPaneAgent: () => options.agent ?? null,
|
||||
getFirstPartyAgentStatus: () => options.firstPartyStatus ?? null,
|
||||
quiescenceMs: QUIESCENCE_MS
|
||||
}
|
||||
const polls = new RuntimeTerminalIdlePolls({
|
||||
...shared,
|
||||
intervalMs: POLL_INTERVAL_MS,
|
||||
getForegroundProcess: () => Promise.resolve(options.foreground ?? null),
|
||||
getLiveLeaf: (leaf) => options.liveLeaf?.() ?? leaf,
|
||||
resolve: (waiter, result) => waiters.resolve(waiter, result)
|
||||
})
|
||||
const wait = new RuntimeTerminalWait(
|
||||
{
|
||||
...shared,
|
||||
defaultTimeoutMs: 60_000,
|
||||
getLivePty: () => (options.pty ? { pty: options.pty } : null),
|
||||
getLiveLeaf: () => ({ leaf: options.leaf ?? makeTuiIdleLeaf() }),
|
||||
startVisibleReadProbe
|
||||
},
|
||||
waiters,
|
||||
polls
|
||||
)
|
||||
return { wait, waiters, polls, startVisibleReadProbe }
|
||||
}
|
||||
|
||||
function watch(promise: Promise<unknown>) {
|
||||
const settled = vi.fn()
|
||||
void promise.then(
|
||||
(value) => settled({ ok: value }),
|
||||
(error) => settled({ error: errorMessage(error) })
|
||||
)
|
||||
return settled
|
||||
}
|
||||
|
||||
/** Keeps the record "streaming": output stays younger than the quiescence window. */
|
||||
async function advanceWhileStreaming(
|
||||
record: { lastOutputAt: number | null },
|
||||
ticks: number
|
||||
): Promise<void> {
|
||||
for (let tick = 0; tick < ticks; tick += 1) {
|
||||
record.lastOutputAt = Date.now()
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
describe('tui-idle evidence ranking', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('refuses a stored name-only idle while the pane is still streaming', async () => {
|
||||
const pty = makeTuiIdlePty({ lastAgentStatus: 'idle', lastOscTitle: NAME_ONLY_TITLE })
|
||||
const { wait } = createWait({ pty, agent: 'codex' })
|
||||
const settled = watch(wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 60_000 }))
|
||||
|
||||
await advanceWhileStreaming(pty, 4)
|
||||
expect(settled).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('settles a name-only idle once the pane has been quiet for the window', async () => {
|
||||
const pty = makeTuiIdlePty({ lastAgentStatus: 'idle', lastOscTitle: NAME_ONLY_TITLE })
|
||||
const { wait } = createWait({ pty, agent: 'codex' })
|
||||
const settled = watch(wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 60_000 }))
|
||||
|
||||
await advanceWhileStreaming(pty, 2)
|
||||
expect(settled).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(QUIESCENCE_MS + POLL_INTERVAL_MS)
|
||||
expect(settled).toHaveBeenCalledWith({ ok: expect.objectContaining({ satisfied: true }) })
|
||||
})
|
||||
|
||||
it('settles an explicit idle title immediately, with no quiescence at all', async () => {
|
||||
const pty = makeTuiIdlePty({ lastAgentStatus: 'idle', lastOscTitle: EXPLICIT_IDLE_TITLE })
|
||||
const { wait } = createWait({ pty, agent: 'codex' })
|
||||
await expect(
|
||||
wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 60_000 })
|
||||
).resolves.toMatchObject({ satisfied: true })
|
||||
})
|
||||
|
||||
// Why this case exists: tier 1 used to read only the renderer-synced pane title, so a
|
||||
// daemon-hosted pane with no renderer dropped its explicit `Codex ready` to the
|
||||
// quiescence lane and waited the whole window for a result it already had.
|
||||
it('reads an explicit idle title off the record when no renderer published one', async () => {
|
||||
const leaf = makeTuiIdleLeaf({
|
||||
lastAgentStatus: 'idle',
|
||||
lastOscTitle: EXPLICIT_IDLE_TITLE,
|
||||
paneTitle: null
|
||||
})
|
||||
const { wait } = createWait({ leaf, agent: 'codex', tabTitle: null })
|
||||
await expect(
|
||||
wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 60_000 })
|
||||
).resolves.toMatchObject({ satisfied: true })
|
||||
})
|
||||
|
||||
it('lets the agent own status stream veto an otherwise-quiet name-only idle', async () => {
|
||||
const pty = makeTuiIdlePty({
|
||||
lastAgentStatus: 'idle',
|
||||
lastOscTitle: NAME_ONLY_TITLE,
|
||||
lastOutputAt: Date.now() - QUIESCENCE_MS * 4
|
||||
})
|
||||
const { wait } = createWait({
|
||||
pty,
|
||||
agent: 'codex',
|
||||
firstPartyStatus: { state: 'working', updatedAt: Date.now() }
|
||||
})
|
||||
const settled = watch(wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 60_000 }))
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 3)
|
||||
expect(settled).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why the scoping: demoting every name-only title left agents that emit their NAME and
|
||||
// nothing else at rest with no settle signal at all. A real idle Grok pane repaints its
|
||||
// banner about four times a second forever, so output never quiesces and the wait ran to
|
||||
// timeout — a total loss of tui-idle for that provider.
|
||||
it('settles immediately for an agent that never emits anything but its name', async () => {
|
||||
const pty = makeTuiIdlePty({ lastAgentStatus: 'idle', lastOscTitle: 'grok' })
|
||||
const { wait } = createWait({ pty, agent: 'grok' })
|
||||
await expect(
|
||||
wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 60_000 })
|
||||
).resolves.toMatchObject({ satisfied: true })
|
||||
})
|
||||
|
||||
it('falls back to the title when the pane carries no launch metadata', async () => {
|
||||
const pty = makeTuiIdlePty({ lastAgentStatus: 'idle', lastOscTitle: NAME_ONLY_TITLE })
|
||||
const { wait } = createWait({ pty, agent: null })
|
||||
const settled = watch(wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 60_000 }))
|
||||
await advanceWhileStreaming(pty, 3)
|
||||
expect(settled).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: `syncWindowGraph` rebuilds leaf records, so a poll that keeps reading the record it
|
||||
// captured sees a frozen `lastOutputAt`, and its quiescence gate passes while the real pane
|
||||
// is still streaming.
|
||||
it('tracks the live leaf record across a graph sync instead of a frozen capture', async () => {
|
||||
const registered = makeTuiIdleLeaf({ lastAgentStatus: 'idle', lastOscTitle: NAME_ONLY_TITLE })
|
||||
let live = registered
|
||||
const { wait } = createWait({ leaf: registered, agent: 'codex', liveLeaf: () => live })
|
||||
const settled = watch(wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 60_000 }))
|
||||
|
||||
// The renderer republishes: a brand-new object replaces the captured one.
|
||||
live = makeTuiIdleLeaf({ lastAgentStatus: 'idle', lastOscTitle: NAME_ONLY_TITLE })
|
||||
registered.lastOutputAt = Date.now() - QUIESCENCE_MS * 10
|
||||
await advanceWhileStreaming(live, 4)
|
||||
expect(settled).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never settles tui-idle on a permission status', async () => {
|
||||
const pty = makeTuiIdlePty({
|
||||
lastAgentStatus: 'permission',
|
||||
lastOscTitle: 'Codex - action required'
|
||||
})
|
||||
const { wait } = createWait({ pty, agent: 'codex' })
|
||||
const settled = watch(wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 60_000 }))
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4 + QUIESCENCE_MS)
|
||||
expect(settled).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
const E2E_WORKTREE_ID = 'repo-1::/tmp/name-only-idle'
|
||||
const E2E_LEAF_ID = '33333333-3333-4333-8333-333333333333'
|
||||
const E2E_PTY_ID = 'pty-name-only-idle'
|
||||
const WORKING_TITLE = '⠋ Codex'
|
||||
const ESC = String.fromCharCode(27)
|
||||
const BEL = String.fromCharCode(7)
|
||||
|
||||
const E2E_GRAPH = {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: E2E_WORKTREE_ID,
|
||||
title: 'Agent',
|
||||
activeLeafId: E2E_LEAF_ID,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: E2E_WORKTREE_ID,
|
||||
leafId: E2E_LEAF_ID,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: E2E_PTY_ID,
|
||||
paneTitle: null,
|
||||
title: ''
|
||||
}
|
||||
]
|
||||
} satisfies RuntimeSyncWindowGraph
|
||||
|
||||
async function makeRuntime(launchAgent?: TuiAgent) {
|
||||
// The agent process stays in the foreground; only its output and title move.
|
||||
const runtime = makeTuiIdleRuntime({
|
||||
repoPath: '/tmp/name-only-idle',
|
||||
getForegroundProcess: async () => 'codex'
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, E2E_GRAPH)
|
||||
if (launchAgent) {
|
||||
runtime.registerPty(E2E_PTY_ID, E2E_WORKTREE_ID, null, {
|
||||
tabId: 'tab-1',
|
||||
leafId: E2E_LEAF_ID,
|
||||
incarnationId: 'name-only-incarnation',
|
||||
agentLaunchAuthority: { launchToken: 'name-only-launch', launchAgent }
|
||||
})
|
||||
}
|
||||
const { terminals } = await runtime.listTerminals(`id:${E2E_WORKTREE_ID}`)
|
||||
return { runtime, handle: terminals[0].handle }
|
||||
}
|
||||
|
||||
function oscTitle(title: string): string {
|
||||
return `${ESC}]0;${title}${BEL}`
|
||||
}
|
||||
|
||||
describe('tui-idle over the live OSC title pipeline', () => {
|
||||
it('does not settle on a name-only title arriving mid-stream', async () => {
|
||||
const { runtime, handle } = await makeRuntime('codex')
|
||||
runtime.onPtyData(E2E_PTY_ID, `${oscTitle(WORKING_TITLE)}building\n`, Date.now())
|
||||
|
||||
const waiting = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 250 })
|
||||
// The agent is mid-turn and repaints its title to the bare product name.
|
||||
runtime.onPtyData(E2E_PTY_ID, `${oscTitle(NAME_ONLY_TITLE)}more output\n`, Date.now())
|
||||
|
||||
await expect(waiting).rejects.toThrow('timeout')
|
||||
})
|
||||
|
||||
it('settles when the agent reports idle explicitly', async () => {
|
||||
const { runtime, handle } = await makeRuntime('codex')
|
||||
runtime.onPtyData(E2E_PTY_ID, `${oscTitle(WORKING_TITLE)}building\n`, Date.now())
|
||||
|
||||
const waiting = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_000 })
|
||||
runtime.onPtyData(E2E_PTY_ID, `${oscTitle(NAME_ONLY_TITLE)}more output\n`, Date.now())
|
||||
runtime.onPtyData(E2E_PTY_ID, oscTitle(EXPLICIT_IDLE_TITLE), Date.now())
|
||||
|
||||
await expect(waiting).resolves.toMatchObject({ condition: 'tui-idle', satisfied: true })
|
||||
})
|
||||
|
||||
it('refuses a name-only title observed before the waiter registered', async () => {
|
||||
const { runtime, handle } = await makeRuntime('codex')
|
||||
runtime.onPtyData(E2E_PTY_ID, `${oscTitle(NAME_ONLY_TITLE)}output\n`, Date.now())
|
||||
|
||||
await expect(
|
||||
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 250 })
|
||||
).rejects.toThrow('timeout')
|
||||
})
|
||||
|
||||
it('still settles for an agent whose only rest signal is its name', async () => {
|
||||
const { runtime, handle } = await makeRuntime('grok')
|
||||
runtime.onPtyData(E2E_PTY_ID, `${oscTitle('grok')}banner\n`, Date.now())
|
||||
|
||||
await expect(
|
||||
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_000 })
|
||||
).resolves.toMatchObject({ condition: 'tui-idle', satisfied: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
// Real agent-TUI stand-in for tui-idle-name-only-real-pty.integration.test.ts.
|
||||
// Emits a genuine name-only OSC title while streaming, then settles per mode.
|
||||
const mode = process.argv[2]
|
||||
const workMs = Number(process.argv[3] ?? 6000)
|
||||
const osc = (title) => `]0;${title}`
|
||||
|
||||
process.stdout.write(osc('Codex'))
|
||||
const end = Date.now() + workMs
|
||||
const streaming = setInterval(() => {
|
||||
if (Date.now() >= end) {
|
||||
clearInterval(streaming)
|
||||
if (mode === 'explicit-idle') {
|
||||
process.stdout.write(osc('Codex ready'))
|
||||
}
|
||||
return
|
||||
}
|
||||
process.stdout.write(`analysing chunk ${Date.now()}\n`)
|
||||
}, 250)
|
||||
|
||||
// Stay alive so the PTY foreground process remains this agent, never the shell.
|
||||
setInterval(() => {}, 1 << 30)
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { AgentStatus } from '../../shared/agent-detection'
|
||||
import { isFreshNonDoneAgentStatus } from '../../shared/agent-status-freshness'
|
||||
import type { AgentStatusState } from '../../shared/agent-status-types'
|
||||
import { getSyntheticAgentTerminalTitle } from '../../shared/synthetic-agent-title'
|
||||
import { resolveExplicitTerminalTitleAgentType } from '../../shared/terminal-title-agent-type'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import { detectExplicitIdleStatusFromTitle } from './terminal-wait-detection'
|
||||
|
||||
/**
|
||||
* Ranking the evidence that a `tui-idle` wait may settle on.
|
||||
*
|
||||
* Why a ranking: a thinking TUI and a finished TUI are both silent, so the absence
|
||||
* of a working marker can never prove completion. `detectAgentStatusFromTitle`
|
||||
* DEFAULTS a name-only agent title to `idle` — the sidebar needs that to clear a
|
||||
* stale spinner (#1437) — so a busy Codex/Devin pane is routinely titled idle, and
|
||||
* accepting it satisfied a wait in ~0s mid-turn (#6011).
|
||||
*
|
||||
* 1. POSITIVE — the agent states it is ready: an explicit idle marker in its own
|
||||
* title, or a known ready-prompt body.
|
||||
* 2. VETO — a fresh first-party agent status (OSC 9999) saying working/blocked/
|
||||
* waiting. The agent's own account of itself outranks anything inferred.
|
||||
* 3. ABSENCE — a name-only title, or a quiet non-shell foreground process. A last
|
||||
* resort, and only once sustained.
|
||||
*
|
||||
* Why derived here rather than stamped onto the record at write time: `syncWindowGraph`
|
||||
* rebuilds every leaf from an explicit field list, so a bespoke provenance field is
|
||||
* silently dropped on any renderer publish and the verdict silently flips. `lastOscTitle`
|
||||
* is copied, so reading the rank back off it cannot decay.
|
||||
*/
|
||||
|
||||
export type TuiIdleEvidenceRecord = {
|
||||
lastAgentStatus: AgentStatus | null
|
||||
lastOutputAt: number | null
|
||||
lastOscTitle?: string | null
|
||||
}
|
||||
|
||||
export type FirstPartyAgentStatus = { state: AgentStatusState; updatedAt: number } | null
|
||||
|
||||
/** Tier 1: an idle marker the agent put in a title itself. */
|
||||
export function hasExplicitIdleTitle(
|
||||
record: TuiIdleEvidenceRecord,
|
||||
rendererTitle?: string | null
|
||||
): boolean {
|
||||
// Why lastOscTitle too, not just the renderer's pane title: a daemon-hosted or
|
||||
// background pane has no renderer publishing a title, so reading only the synced
|
||||
// one dropped an explicit `Codex ready` to the tier-3 lane and delayed it by the
|
||||
// whole quiescence window.
|
||||
for (const title of [rendererTitle, record.lastOscTitle]) {
|
||||
if (title && detectExplicitIdleStatusFromTitle(title) === 'idle') {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Tier 2: the agent's own status stream says this turn is still open. */
|
||||
export function hasFreshWorkingFirstPartyStatus(status: FirstPartyAgentStatus): boolean {
|
||||
return isFreshNonDoneAgentStatus(status ?? undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a name-only title from `agent` may be held to the tier-3 quiescence demand.
|
||||
*
|
||||
* Only for agents that go on to announce rest with an explicit title of their own (the
|
||||
* hook-driven `Codex ready` / `Devin ready`). Grok, Copilot, Aider, Mimo, agy and
|
||||
* OpenCode emit their NAME and nothing more at rest, so holding them to it leaves no
|
||||
* settle signal at all: a real idle Grok pane repaints its banner about four times a
|
||||
* second forever, so the stream never quiesces and the wait runs to timeout.
|
||||
*/
|
||||
export function nameOnlyIdleNeedsCorroboration(
|
||||
agent: TuiAgent | null | undefined,
|
||||
title?: string | null
|
||||
): boolean {
|
||||
// Why the title fallback: an adopted pane carries no launch metadata, but its
|
||||
// name-only title is exactly the thing that names the agent.
|
||||
const resolved = agent ?? (title ? resolveExplicitTerminalTitleAgentType(title) : null)
|
||||
return getSyntheticAgentTerminalTitle(resolved, 'done') !== null
|
||||
}
|
||||
|
||||
/** Tier 3: a title-derived idle, usable only once the stream has also gone quiet. */
|
||||
export function hasSustainedTitleIdle(
|
||||
record: TuiIdleEvidenceRecord,
|
||||
agent: TuiAgent | null | undefined,
|
||||
quiescenceMs: number
|
||||
): boolean {
|
||||
if (record.lastAgentStatus !== 'idle') {
|
||||
return false
|
||||
}
|
||||
if (!nameOnlyIdleNeedsCorroboration(agent, record.lastOscTitle)) {
|
||||
// The title is the only rest signal this agent emits, so there is nothing to wait for.
|
||||
return true
|
||||
}
|
||||
// Why not "no timestamp means nothing to debounce": an adopted or daemon-backed pane has
|
||||
// no local output clock, so for an agent that WILL announce rest explicitly there is no
|
||||
// corroboration available at all. Settling here let a busy Codex/Devin satisfy the wait
|
||||
// from a name-only title (#6011); hold out for tier 1/2 or the caller's timeout instead.
|
||||
if (record.lastOutputAt === null) {
|
||||
return false
|
||||
}
|
||||
return Date.now() - record.lastOutputAt >= quiescenceMs
|
||||
}
|
||||
|
||||
/**
|
||||
* Tier 3, cold start: Orca launched a known agent on this PTY, so a quiet non-shell
|
||||
* foreground process is an agent still booting, not one sitting at its prompt. Resolving
|
||||
* on it is what let `dispatch --inject` write into a TUI that had not yet attached its
|
||||
* reader and silently lose the prompt (#9976).
|
||||
*/
|
||||
export function quietForegroundProcessProvesTuiIdle(agent: TuiAgent | null | undefined): boolean {
|
||||
return !agent
|
||||
}
|
||||
|
||||
export type TuiIdleSatisfactionInput = {
|
||||
record: TuiIdleEvidenceRecord
|
||||
/** Renderer-synced pane/tab title, when one exists. */
|
||||
rendererTitle?: string | null
|
||||
/** Tier 1 body evidence: a known ready prompt, or an adopted pane's explicit title.
|
||||
* A thunk because producing it means building the pane's wait text and lowercasing it
|
||||
* (~11us and a multi-KB string on a full tail); the title check below usually answers
|
||||
* first, and then none of that has to happen at all. */
|
||||
readPositiveBodyEvidence: () => boolean
|
||||
agent: TuiAgent | null | undefined
|
||||
firstPartyStatus: FirstPartyAgentStatus
|
||||
quiescenceMs: number
|
||||
}
|
||||
|
||||
/** The one place the three tiers are combined; every satisfaction site routes here. */
|
||||
export function isTuiIdleSatisfied(input: TuiIdleSatisfactionInput): boolean {
|
||||
// Why the title before the body: both are tier 1, so either settles, but the title is a
|
||||
// memoized lookup and the body is a fresh multi-KB scan. Same verdict, cheaper order.
|
||||
if (hasExplicitIdleTitle(input.record, input.rendererTitle) || input.readPositiveBodyEvidence()) {
|
||||
return true
|
||||
}
|
||||
if (hasFreshWorkingFirstPartyStatus(input.firstPartyStatus)) {
|
||||
return false
|
||||
}
|
||||
return hasSustainedTitleIdle(input.record, input.agent, input.quiescenceMs)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import * as pty from 'node-pty'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { OrcaRuntimeService } from './orca-runtime'
|
||||
import { makeTuiIdleRuntime } from './tui-idle-wait-test-harness'
|
||||
import type { RuntimeSyncWindowGraph } from '../../shared/runtime-types'
|
||||
import { TERMINAL_LIFECYCLE_METHODS } from './rpc/methods/terminal/terminal-lifecycle-methods'
|
||||
import { getForegroundProcessName } from '../../relay/pty-shell-utils'
|
||||
|
||||
// #6011 end-to-end: a REAL pty running a REAL process that emits a REAL name-only
|
||||
// OSC title while streaming must not satisfy `orca terminal wait --for tui-idle`.
|
||||
// Everything below is live — real bytes, real `ps` foreground reads, real timers —
|
||||
// because the bug was a wait that returned satisfied in ~0s, so timing IS the proof.
|
||||
|
||||
const FIXTURE = fileURLToPath(new URL('./tui-idle-agent-fixture.mjs', import.meta.url))
|
||||
const WORKTREE_ID = 'repo-1::/tmp/tui-idle-real-pty'
|
||||
const TAB_ID = '55555555-5555-4555-8555-555555555555'
|
||||
const LEAF_ID = '66666666-6666-4666-8666-666666666666'
|
||||
const PTY_ID = 'pty-tui-idle-real'
|
||||
|
||||
const waitMethod = TERMINAL_LIFECYCLE_METHODS.find((method) => method.name === 'terminal.wait')!
|
||||
|
||||
const running: pty.IPty[] = []
|
||||
|
||||
afterEach(() => {
|
||||
while (running.length > 0) {
|
||||
try {
|
||||
running.pop()?.kill()
|
||||
} catch {
|
||||
// The fixture may already be gone.
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function startRealAgentPane(mode: 'explicit-idle' | 'quiet', workMs: number) {
|
||||
const child = pty.spawn(process.execPath, [FIXTURE, mode, String(workMs)], {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
cwd: '/tmp'
|
||||
})
|
||||
running.push(child)
|
||||
|
||||
// Real foreground read against the real pty: the same helper the relay serves
|
||||
// `pty.getForegroundProcess` with, so corroboration is host-produced here too.
|
||||
const runtime = makeTuiIdleRuntime({
|
||||
repoPath: '/tmp/tui-idle-real-pty',
|
||||
getForegroundProcess: () => getForegroundProcessName(child.pid, child.process || null)
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
const graph: RuntimeSyncWindowGraph = {
|
||||
tabs: [
|
||||
{
|
||||
tabId: TAB_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
title: 'Agent',
|
||||
activeLeafId: LEAF_ID,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: TAB_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
leafId: LEAF_ID,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: PTY_ID,
|
||||
paneTitle: null,
|
||||
title: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
runtime.syncWindowGraph(1, graph)
|
||||
|
||||
const transcript: string[] = []
|
||||
child.onData((data) => {
|
||||
transcript.push(data)
|
||||
runtime.onPtyData(PTY_ID, data, Date.now())
|
||||
})
|
||||
|
||||
const { terminals } = await runtime.listTerminals(`id:${WORKTREE_ID}`)
|
||||
return { runtime, transcript, handle: terminals[0].handle }
|
||||
}
|
||||
|
||||
/** Exactly what `orca terminal wait --terminal <h> --for tui-idle` reaches over RPC. */
|
||||
async function terminalWait(
|
||||
runtime: OrcaRuntimeService,
|
||||
terminal: string,
|
||||
timeoutMs: number
|
||||
): Promise<{ satisfied: boolean; elapsedMs: number }> {
|
||||
const startedAt = Date.now()
|
||||
try {
|
||||
const result = await waitMethod.handler(
|
||||
{ terminal, for: 'tui-idle', timeoutMs },
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: terminal.wait reads only `runtime` off its context; the rest is request plumbing this fixture has no use for.
|
||||
{ runtime } as Parameters<typeof waitMethod.handler>[1]
|
||||
)
|
||||
return { satisfied: result.wait.satisfied === true, elapsedMs: Date.now() - startedAt }
|
||||
} catch (error) {
|
||||
// Why only `timeout`: an unsatisfied wait is the outcome under test, but any other
|
||||
// failure means the harness broke and must not read as a passing refusal.
|
||||
if ((error instanceof Error ? error.message : String(error)) !== 'timeout') {
|
||||
throw error
|
||||
}
|
||||
return { satisfied: false, elapsedMs: Date.now() - startedAt }
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('tui-idle against a real agent pty', () => {
|
||||
it('does not satisfy while the real process streams under a name-only title', async () => {
|
||||
const { runtime, transcript, handle } = await startRealAgentPane('quiet', 60_000)
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
|
||||
// The OSC title really did reach the runtime as control bytes, not literal text.
|
||||
expect(transcript.join('')).toContain(']0;Codex')
|
||||
|
||||
const outcome = await terminalWait(runtime, handle, 8_000)
|
||||
expect(outcome.satisfied).toBe(false)
|
||||
expect(outcome.elapsedMs).toBeGreaterThanOrEqual(7_500)
|
||||
}, 25_000)
|
||||
|
||||
it('satisfies once the real process emits an explicit idle title', async () => {
|
||||
const { runtime, handle } = await startRealAgentPane('explicit-idle', 3_000)
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
|
||||
const outcome = await terminalWait(runtime, handle, 20_000)
|
||||
expect(outcome.satisfied).toBe(true)
|
||||
expect(outcome.elapsedMs).toBeGreaterThanOrEqual(1_500)
|
||||
}, 28_000)
|
||||
|
||||
it('satisfies once the real process goes quiet with the agent still in foreground', async () => {
|
||||
const { runtime, handle } = await startRealAgentPane('quiet', 3_000)
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
|
||||
const outcome = await terminalWait(runtime, handle, 20_000)
|
||||
expect(outcome.satisfied).toBe(true)
|
||||
// Corroboration is never instant: quiescence must elapse after the last byte.
|
||||
expect(outcome.elapsedMs).toBeGreaterThanOrEqual(3_000)
|
||||
}, 28_000)
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { getDefaultWorkspaceSession } from '../../shared/constants'
|
||||
import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
|
||||
|
||||
/**
|
||||
* Shared fixtures for the tui-idle wait suites.
|
||||
*
|
||||
* Why a module rather than per-file helpers: the runtime's store, PTY controller and
|
||||
* terminal records are wide contracts (40 members on the controller alone) and the wait
|
||||
* path reads a handful of fields from each. Standing up a complete instance per test
|
||||
* would bury the behaviour under fixture noise, so the partial doubles are built once
|
||||
* here and every cast that needs is confined to this file.
|
||||
*/
|
||||
|
||||
/** The fields the tui-idle wait path actually reads off a PTY record. */
|
||||
export type TuiIdlePtyFixture = Pick<
|
||||
RuntimePtyWorktreeRecord,
|
||||
'ptyId' | 'lastAgentStatus' | 'lastOscTitle' | 'lastOutputAt' | 'tailBuffer' | 'preview'
|
||||
> &
|
||||
Partial<RuntimePtyWorktreeRecord>
|
||||
|
||||
/** The fields the tui-idle wait path actually reads off a leaf record. */
|
||||
export type TuiIdleLeafFixture = Pick<
|
||||
RuntimeLeafRecord,
|
||||
| 'tabId'
|
||||
| 'leafId'
|
||||
| 'ptyId'
|
||||
| 'lastAgentStatus'
|
||||
| 'lastOscTitle'
|
||||
| 'lastOutputAt'
|
||||
| 'paneTitle'
|
||||
| 'tailBuffer'
|
||||
| 'preview'
|
||||
> &
|
||||
Partial<RuntimeLeafRecord>
|
||||
|
||||
// The fixture types above pin every field the wait path reads; the remaining record
|
||||
// members are inert here, and the compiler still checks the pinned ones at each call site.
|
||||
const asPty = (fixture: TuiIdlePtyFixture): RuntimePtyWorktreeRecord =>
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: TuiIdlePtyFixture pins every field the wait path reads.
|
||||
fixture as unknown as RuntimePtyWorktreeRecord
|
||||
|
||||
const asLeaf = (fixture: TuiIdleLeafFixture): RuntimeLeafRecord =>
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: TuiIdleLeafFixture pins every field the wait path reads.
|
||||
fixture as unknown as RuntimeLeafRecord
|
||||
|
||||
export function makeTuiIdlePty(
|
||||
overrides: Partial<RuntimePtyWorktreeRecord> = {}
|
||||
): RuntimePtyWorktreeRecord {
|
||||
return asPty({
|
||||
ptyId: 'pty-1',
|
||||
connected: true,
|
||||
lastExitCode: null,
|
||||
lastExitCause: null,
|
||||
lastAgentStatus: null,
|
||||
lastOscTitle: null,
|
||||
lastOutputAt: Date.now(),
|
||||
tailBuffer: [],
|
||||
tailPartialLine: '',
|
||||
preview: '',
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
export function makeTuiIdleLeaf(overrides: Partial<RuntimeLeafRecord> = {}): RuntimeLeafRecord {
|
||||
return asLeaf({
|
||||
tabId: 'tab-1',
|
||||
leafId: 'leaf-1',
|
||||
ptyId: 'pty-1',
|
||||
connected: true,
|
||||
lastExitCode: null,
|
||||
lastExitCause: null,
|
||||
lastAgentStatus: null,
|
||||
lastOscTitle: null,
|
||||
lastOutputAt: Date.now(),
|
||||
paneTitle: null,
|
||||
tailBuffer: [],
|
||||
tailPartialLine: '',
|
||||
preview: '',
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
function makeStore(repoPath: string) {
|
||||
return {
|
||||
getWorkspaceSession: () => getDefaultWorkspaceSession(),
|
||||
setWorkspaceSession: () => {},
|
||||
getRepos: () => [
|
||||
{
|
||||
id: 'repo-1',
|
||||
path: repoPath,
|
||||
displayName: 'fixture',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 0
|
||||
}
|
||||
],
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getWorktreeMeta: () => undefined,
|
||||
setWorktreeMeta: () => {},
|
||||
removeWorktreeMeta: () => {},
|
||||
getSettings: () => ({ workspaceDir: '/tmp/workspaces' }),
|
||||
getProjects: () => []
|
||||
}
|
||||
}
|
||||
|
||||
export type TuiIdleRuntimeOptions = {
|
||||
repoPath: string
|
||||
getForegroundProcess: () => Promise<string | null>
|
||||
}
|
||||
|
||||
/** A runtime wired with the narrowest store and controller the wait path needs. */
|
||||
export function makeTuiIdleRuntime(options: TuiIdleRuntimeOptions): OrcaRuntimeService {
|
||||
// RuntimeStore and RuntimePtyController are wide contracts; the wait path calls only the
|
||||
// members provided here, and a missing one throws loudly rather than silently passing.
|
||||
const runtime = new OrcaRuntimeService(
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: partial store double; the wait path reads only the members defined above.
|
||||
makeStore(options.repoPath) as never
|
||||
)
|
||||
runtime.setPtyController(
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: partial controller double; the wait path calls only the members listed here.
|
||||
{
|
||||
spawn: async () => ({ id: 'fixture-pty' }),
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: options.getForegroundProcess,
|
||||
listProcesses: async () => [],
|
||||
hasPty: () => true
|
||||
} as never
|
||||
)
|
||||
return runtime
|
||||
}
|
||||
|
||||
export function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
@@ -150,6 +150,7 @@ const INVENTORY: readonly InventoryGroup[] = [
|
||||
classification: 'identity-consumer',
|
||||
paths: [
|
||||
['mobile/src/session/mobile-terminal-tab-agent.ts', 2],
|
||||
['src/main/runtime/tui-idle-evidence.ts', 2],
|
||||
['src/renderer/src/lib/open-tab-occupant-agent.ts', 2],
|
||||
['src/renderer/src/lib/use-tab-agent.ts', 3]
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user