fix(runtime): renaming a terminal no longer hides its running agent (#31428)

This commit is contained in:
Kelvin Amoaba
2026-10-21 18:22:37 +00:00
committed by GitHub
parent 3d6a945b54
commit dde7bf8533
10 changed files with 160 additions and 43 deletions
@@ -441,6 +441,34 @@ describe('headless hook agent-status projection (#11761)', () => {
})
})
// A rename is the tab's name, not process title evidence: the CLI status read keeps the agent.
it('keeps the terminal agent status running after the pane is renamed to a shell name', async () => {
const runtime = await createRuntimeWithHookRows([
hookRow({
state: 'working',
prompt: 'Working',
toolName: undefined,
interactivePrompt: undefined
})
])
observePaneTitle(runtime, '⠋ Claude')
const { tabs } = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)
const handle = tabs[0]?.type === 'terminal' ? tabs[0].terminal : undefined
if (!handle) {
throw new Error('expected a live terminal handle')
}
await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({
isRunningAgent: true
})
await runtime.renameTerminal(handle, 'bash')
await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({
isRunningAgent: true,
status: 'working'
})
})
it('retires the renamed agent when its OSC title returns to the shell', async () => {
const runtime = await createRuntimeWithHookRows([
hookRow({ state: 'working', prompt: '', toolName: undefined, interactivePrompt: undefined })
@@ -1,11 +1,13 @@
import './orca-runtime-test-lifecycle.spec'
import type { RuntimeStore } from './runtime-store-contract'
import { getDefaultWorkspaceSession } from '../../shared/constants'
import { describe, expect, it, vi } from 'vitest'
import { createMobileCreateTestNotifier } from './orca-runtime-test-scenario-builders.spec'
import { OrcaRuntimeService } from './orca-runtime-test-mocks.spec'
import {
HEADLESS_LEAF_ID,
TEST_WORKTREE_ID,
TEST_WORKTREE_PATH,
makeRuntimeStoreWithWorkspaceSession,
makeWorkspaceSessionWithHeadlessTerminal
} from './orca-runtime-test-fixtures.spec'
@@ -92,6 +94,104 @@ describe('terminal rename before renderer graph hydration', () => {
})
})
// The name is display only (#20626): every reader that shows the terminal still shows it.
describe('terminal rename display', () => {
it('reveals a renamed background terminal under its name', async () => {
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(getDefaultWorkspaceSession())
const runtime = new OrcaRuntimeService(runtimeStore)
runtime.setPtyController({
spawn: vi.fn(async () => ({ id: 'background-pty' })),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
const notifier = createMobileCreateTestNotifier(vi.fn())
runtime.setNotifier(notifier)
const created = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
tabId: 'host-tab',
leafId: HEADLESS_LEAF_ID,
presentation: 'background'
})
runtime.onPtyData('background-pty', '\x1b]0;bash\x07', Date.now())
await runtime.renameTerminal(created.handle, 'Review worker')
await runtime.focusTerminal(created.handle)
expect(notifier.revealTerminalSession.mock.calls.at(-1)?.[1]).toMatchObject({
title: 'Review worker'
})
})
it('shows an orphan terminal under its new name', async () => {
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(getDefaultWorkspaceSession())
const runtime = new OrcaRuntimeService(runtimeStore)
runtime.registerPty('orphan-pty', TEST_WORKTREE_ID)
runtime.onPtyData('orphan-pty', '\x1b]0;bash\x07', Date.now())
const orphan = (await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)).terminals.find(
(terminal) => terminal.ptyId === 'orphan-pty'
)
if (!orphan?.orphaned) {
throw new Error('expected an orphan terminal')
}
await runtime.renameTerminal(orphan.handle, 'Recovered worker')
await expect(runtime.showTerminal(orphan.handle)).resolves.toMatchObject({
title: 'Recovered worker'
})
})
it('keeps an orphan terminal name once a tab adopts it', async () => {
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({
...getDefaultWorkspaceSession(),
tabsByWorktree: { [TEST_WORKTREE_ID]: [] }
})
const runtime = new OrcaRuntimeService(runtimeStore)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
listProcesses: async () => [
{
id: 'orphan-pty',
incarnationId: 'orphan-incarnation',
terminalHandle: 'term_orphan',
title: 'bash',
cwd: TEST_WORKTREE_PATH,
worktreeId: TEST_WORKTREE_ID,
wslDistro: null
}
]
})
const before = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)
runtime.onPtyData('orphan-pty', '\x1b]0;bash\x07', Date.now())
await runtime.renameTerminal('term_orphan', 'Recovered worker')
const adopted = await runtime.adoptTerminalOrphans({
worktree: `id:${TEST_WORKTREE_ID}`,
expectedTopologyRevision: before.topologyRevisions?.[TEST_WORKTREE_ID] ?? 0,
claims: [
{
terminal: 'term_orphan',
ptyId: 'orphan-pty',
incarnationId: 'orphan-incarnation',
tabId: 'recovered-tab',
leafId: HEADLESS_LEAF_ID
}
]
})
await expect(runtime.showTerminal('term_orphan')).resolves.toMatchObject({
title: 'Recovered worker'
})
expect(
adopted.snapshot.tabs.find(
(tab) => tab.type === 'terminal' && tab.parentTabId === 'recovered-tab'
)
).toMatchObject({ title: 'Recovered worker' })
})
})
describe('terminal view rename characterization', () => {
function notifier(renameTerminal: RuntimeNotifier['renameTerminal']): RuntimeNotifier {
return {
@@ -131,8 +231,7 @@ describe('terminal view rename characterization', () => {
runtime['touchMobileSessionSnapshotsForPty'] = function (this: OrcaRuntimeService, ptyId) {
receivers.push(this)
expect(ptyId).toBe('rename-pty')
expect(pty.title).toBe('Pinned')
expect(pty.titleUpdatedAt).toBeGreaterThan(0)
expect(pty.title).toBeNull()
events.push('touch')
}
await expect(runtime.renameTerminal(handle, 'Pinned')).resolves.toEqual({
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { getDefaultWorkspaceSession } from '../../../shared/constants'
import {
AGENT_PROMPT_BRACKETED_PASTE_END,
AGENT_PROMPT_BRACKETED_PASTE_START,
@@ -14,6 +15,7 @@ import {
TEST_WORKTREE_ID,
TEST_WORKTREE_PATH,
UUID_RE,
makeRuntimeStoreWithWorkspaceSession,
renderGateCapMs,
store
} from '../orca-runtime-test-fixtures.spec'
@@ -538,7 +540,8 @@ describe('OrcaRuntimeService', () => {
})
it('renames background terminal handles without requiring a visible tab', async () => {
const runtime = new OrcaRuntimeService(store)
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(getDefaultWorkspaceSession())
const runtime = new OrcaRuntimeService(runtimeStore)
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }),
write: () => true,
@@ -183,20 +183,6 @@ describe('restored turns on a synced leaf', () => {
)
})
it('keeps a restored turn when a manual shell-like label overrides an agent OSC title', () => {
const receivedAt = Date.now() - 60_000
const leaf = {
...leafWithTitle('✳ Claude'),
paneTitle: 'bash',
paneTitleUpdatedAt: 2
}
const result = project(leaf, receivedAt + 1_000, receivedAt)
expect(result.agentStatus).toEqual(
expect.objectContaining({ state: 'working', restoredUnconfirmed: true })
)
})
it('retires to done once the leaf PTY paints a newer shell title', () => {
const receivedAt = Date.now() - 60_000
const result = project(leafWithTitle('bash'), receivedAt + 1_000, receivedAt)
@@ -71,12 +71,7 @@ export function buildRuntimeMobileAgentStatus(
)
: null
const ptyTitleClassification = classifyAgentTitle(ptyTitle)
const titleOmitsAgent = ptyTitle !== null && ptyTitleClassification !== 'agent'
const oscTitle = pty?.lastOscTitle ?? leaf?.lastOscTitle ?? null
// Why: a manual rename is not the shell reclaiming the pane, so the hook session stays addressable (#20626).
const manualTitleOverridesAgentOsc =
titleOmitsAgent && ptyTitle !== oscTitle && classifyAgentTitle(oscTitle) === 'agent'
const nonAgentTitle = titleOmitsAgent && !manualTitleOverridesAgentOsc
const nonAgentTitle = ptyTitle !== null && ptyTitleClassification !== 'agent'
if (nonAgentTitle) {
// Why: non-agent title = shell reclaimed the pane; suppress to clear stuck spinners (#1437), though a live hook signal survives.
const hasLiveHookSignal =
@@ -147,7 +142,6 @@ export function buildRuntimeMobileAgentStatus(
// client's row) and drop the question fields (#12346). Only a later shell title retires it.
const shellTitleRetiredRestored =
hookRow.restored !== null &&
!manualTitleOverridesAgentOsc &&
terminalTitleBlocksExplicitAgentStatus(ptyTitle) &&
(pty?.lastOscTitleEpochMs ?? host.getPtyTitleObservedAtEpochMs?.(leaf?.ptyId ?? null) ?? 0) >=
hookRow.restored.updatedAt
@@ -55,7 +55,8 @@ export function buildRuntimeTerminalOrphanSession(args: {
worktreeId: worktreeId,
title,
defaultTitle: title,
customTitle: null,
// An orphan's rename lives on its PTY until a tab owns it.
customTitle: pty.manualTitle ?? null,
color: null,
sortOrder: tabsById.size,
createdAt: Date.now()
@@ -112,6 +112,8 @@ export type RuntimePtyWorktreeRecord = RuntimeTerminalTailState & {
controllerTitle: string | null
title: string | null
titleUpdatedAt: number | null
/** The name a rename gave this terminal: display only, never title evidence (#20626). */
manualTitle?: string | null
lastOutputAt: number | null
/** See terminal-command-paint.ts; absent until the pane's first output, and again after a gap or a new process. */
commandPaint?: TerminalCommandPaint
@@ -7,8 +7,13 @@ import type {
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import { getLatestPtyTitle } from '../../runtime-worktree-status-projection'
import { copySleepingAgentLaunchConfig } from '../../runtime-agent-launch-resolution'
import type { OrcaRuntimeWithBuildPtyTerminalSummary as PtyTerminalSummaryMethods } from '../query/pty-terminal-summary'
export abstract class OrcaRuntimeWithFocusTerminal extends OrcaRuntimeWithWaitForLeafPtyId {
protected abstract getPtyManualTitle(
...args: Parameters<PtyTerminalSummaryMethods['getPtyManualTitle']>
): ReturnType<PtyTerminalSummaryMethods['getPtyManualTitle']>
async focusTerminal(
handle: string,
options: { navigateHost?: boolean } = {}
@@ -80,7 +85,9 @@ export abstract class OrcaRuntimeWithFocusTerminal extends OrcaRuntimeWithWaitFo
const parsedPaneKey = parsePaneKey(live.pty.paneKey ?? '')
const revealed = await notifier.revealTerminalSession(live.pty.worktreeId, {
ptyId: live.pty.ptyId,
title: getLatestPtyTitle(this.getPtyDisplayRecord(live.pty)),
title:
this.getPtyManualTitle(live.pty, false) ??
getLatestPtyTitle(this.getPtyDisplayRecord(live.pty)),
...(live.pty.launchConfig
? { launchConfig: copySleepingAgentLaunchConfig(live.pty.launchConfig) }
: {}),
@@ -46,10 +46,8 @@ export abstract class OrcaRuntimeWithBuildPtyTerminalSummary extends OrcaRuntime
const pane = parsePaneKey(pty.paneKey ?? '')
const orphaned = !ptyHoldsRecordedSurface(pty, this.ptySurfaceTopology())
// A restarted host's terminal has no title yet; until it has one, its tab's saved name stands in.
const title =
getLatestPtyTitle(this.getPtyDisplayRecord(pty)) ??
(orphaned ? null : this.getSavedTerminalTabCustomTitle(pty.worktreeId, pty.tabId))
const processTitle = getLatestPtyTitle(this.getPtyDisplayRecord(pty))
const title = this.getPtyManualTitle(pty, orphaned) ?? processTitle
const paneTitle =
orphaned || !pty.tabId || !pane
? null
@@ -87,20 +85,20 @@ export abstract class OrcaRuntimeWithBuildPtyTerminalSummary extends OrcaRuntime
...this.resolvePaneAgentIdentityField(
pty.launchAgent,
pty.foregroundAgent,
title,
processTitle,
pty.paneKey ?? null
)
}
}
protected getSavedTerminalTabCustomTitle(
worktreeId: string,
tabId: string | null
): string | null {
const tab = this.getWorkspaceSessionForWorktree(worktreeId)?.tabsByWorktree[worktreeId]?.find(
(candidate) => candidate.id === tabId
)
return tab?.customTitle?.trim() || null
/** The terminal's manual name: its saved tab's, or the rename's while no saved tab holds it. */
protected getPtyManualTitle(pty: RuntimePtyWorktreeRecord, orphaned: boolean): string | null {
const tab = orphaned
? undefined
: this.getWorkspaceSessionForWorktree(pty.worktreeId)?.tabsByWorktree[pty.worktreeId]?.find(
(candidate) => candidate.id === pty.tabId
)
return tab ? tab.customTitle?.trim() || null : (pty.manualTitle ?? null)
}
protected getLiveLeafForHandle(handle: string): {
@@ -240,10 +240,9 @@ export abstract class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRu
}
const pty = this.getLivePtyForHandle(handle)
if (pty) {
pty.pty.title = title
// Why: a manual rename must outrank later agent OSC title updates (which
// win by timestamp), so stamp it as the freshest title.
pty.pty.titleUpdatedAt = Date.now()
// Why: not pty.title — that is the process's own title evidence, and a name there reads as
// the shell taking the pane back (#20626).
pty.pty.manualTitle = title?.trim() || null
this.touchMobileSessionSnapshotsForPty(pty.pty.ptyId)
// Why: without a renderer the rename only lived on the live pty and was
// lost on restart. Persist customTitle so a headless rebuild keeps it.