mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix(runtime): renaming a terminal no longer hides its running agent (#31428)
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user