fix(runtime): retain completion subtitles for paired workspaces (#9930)

* fix(runtime): keep lastAssistantMessage when demoting idle agent status

Paired clients mirror the mobile/session terminal projection. Demoting a
stale working status under a neutral title dropped lastAssistantMessage, so
remote workspace cards lost the completion subtitle that local cards still
show from the in-process hook row.

Fixes #9914

* Format completion field projection consistently

* Preserve completion classification and bound interrupted verdicts

* Keep completed replies out of new live title intervals

* Restrict retained completion previews to completed title status

* Keep mobile session projection within lint file budget

* fix(runtime): keep only completed-turn replies in demoted mobile status

Mid-turn hook snapshots carry tool stdout as lastAssistantMessage; a shell or idle
title must not surface that as the final reply.

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
Wooseong Kim
2026-10-24 00:33:41 -07:00
committed by GitHub
co-authored by Neil Neil
parent 909dce0a0b
commit f4a3860073
3 changed files with 245 additions and 15 deletions
@@ -12,6 +12,46 @@ import { terminalTitleBlocksExplicitAgentStatus } from './runtime-worktree-statu
import type { HookLiveAgentRow } from './runtime-terminal-contracts'
import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
// Why: only a completed turn's reply is final; mid-turn hook snapshots carry tool stdout.
function completedTurnFields(
status: AgentStatusEntry
): Pick<
AgentStatusEntry,
'lastAssistantMessage' | 'lastAssistantMessageIsToolOutput' | 'interrupted'
> {
if (status.state !== 'done') {
return {}
}
return {
...(status.lastAssistantMessage
? {
lastAssistantMessage: status.lastAssistantMessage,
...(status.lastAssistantMessageIsToolOutput != null
? { lastAssistantMessageIsToolOutput: status.lastAssistantMessageIsToolOutput }
: {})
}
: {}),
...(status.interrupted != null ? { interrupted: status.interrupted } : {})
}
}
export function demoteRuntimeMobileAgentStatus(
status: AgentStatusEntry,
pty: RuntimePtyWorktreeRecord | null
): AgentStatusEntry {
return {
state: 'done',
prompt: '',
updatedAt: pty?.lastOscTitleEpochMs ?? status.updatedAt,
stateStartedAt: pty?.lastAgentStatusStartedAtEpochMs ?? status.stateStartedAt,
paneKey: status.paneKey,
stateHistory: [],
agentType: status.agentType,
...completedTurnFields(status),
...(status.providerSession ? { providerSession: status.providerSession } : {})
}
}
export function renewRuntimeMobileAgentStatusFromPtyTitle(
status: AgentStatusEntry | null,
pty: RuntimePtyWorktreeRecord | null,
@@ -59,7 +99,8 @@ export function renewRuntimeMobileAgentStatusFromPtyTitle(
...(status.worktreeId ? { worktreeId: status.worktreeId } : {}),
...(status.tabId ? { tabId: status.tabId } : {}),
...(status.terminalTitle ? { terminalTitle: status.terminalTitle } : {}),
...(status.providerSession ? { providerSession: status.providerSession } : {})
...(status.providerSession ? { providerSession: status.providerSession } : {}),
...(state === 'done' ? completedTurnFields(status) : {})
})
const titleConfirmsState =
(pty.lastAgentStatus === 'working' && status.state === 'working') ||
@@ -0,0 +1,201 @@
import { describe, expect, it, vi } from 'vitest'
import type { AgentStatusEntry } from '../../shared/agent-status-types'
import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types'
import { renewRuntimeMobileAgentStatusFromPtyTitle } from './runtime-mobile-agent-status-projection'
import { projectRuntimeMobileSessionTabs } from './runtime-mobile-session-projection'
import type { RuntimeMobileSessionProjectionHost } from './runtime-mobile-session-projection-contract'
import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
const MESSAGE = '좌→우 가로 파이프라인으로 다시 그렸습니다'
function workingStatus(overrides: Partial<AgentStatusEntry> = {}): AgentStatusEntry {
return {
state: 'working',
prompt: 'stale task',
updatedAt: 1_700_000_000_000,
stateStartedAt: 1_699_999_999_000,
agentType: 'claude',
paneKey: 'tab:leaf',
terminalTitle: 'claude working',
lastAssistantMessage: MESSAGE,
interrupted: false,
stateHistory: [],
...overrides
}
}
describe('mobile completion status projection', () => {
it.each([false, true])(
'keeps the completion subtitle and interrupted verdict %s under a shell title',
(interrupted) => {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: renew reads only the title clock and status fields this stub sets.
const pty = {
lastOscTitle: 'bash',
lastOscTitleEpochMs: 1_700_000_000_500,
lastAgentStatus: 'idle',
lastAgentStatusStartedAtEpochMs: 1_700_000_000_500,
lastAgentStatusRichInvalidatedAtEpochMs: 1_700_000_000_400
} as RuntimePtyWorktreeRecord
expect(
renewRuntimeMobileAgentStatusFromPtyTitle(
workingStatus({ state: 'done', interrupted }),
pty
)
).toMatchObject({
state: 'done',
prompt: '',
agentType: 'claude',
lastAssistantMessage: MESSAGE,
interrupted
})
}
)
it.each(['working', 'permission'] as const)(
'does not carry an interrupted verdict into a new %s title',
(titleState) => {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: renewal reads only the title clock and status fields provided here.
const pty = {
lastOscTitle: 'Claude Code',
lastOscTitleEpochMs: 1_700_000_000_500,
lastAgentStatus: titleState,
lastAgentStatusStartedAtEpochMs: 1_700_000_000_500,
lastAgentStatusRichInvalidatedAtEpochMs: 1_700_000_000_400
} as RuntimePtyWorktreeRecord
const renewed = renewRuntimeMobileAgentStatusFromPtyTitle(
workingStatus({
state: 'done',
interrupted: true,
lastAssistantMessageIsToolOutput: false
}),
pty
)
expect(renewed?.state).toBe(titleState === 'working' ? 'working' : 'blocked')
expect(renewed?.interrupted).toBeUndefined()
expect(renewed?.lastAssistantMessage).toBeUndefined()
expect(renewed?.lastAssistantMessageIsToolOutput).toBeUndefined()
}
)
it.each([
{ titleState: 'working', sourceState: 'done', toolOutput: false },
{ titleState: 'working', sourceState: 'done', toolOutput: true },
{ titleState: 'idle', sourceState: 'done', toolOutput: true },
{ titleState: 'idle', sourceState: 'working', toolOutput: false }
] as const)(
'keeps the reply only for a completed turn under a completed title $titleState/$sourceState/$toolOutput',
({ titleState, sourceState, toolOutput }) => {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: renewal reads only the title clock and status fields provided here.
const pty = {
lastOscTitle: 'Claude Code',
lastOscTitleEpochMs: 1_700_000_000_500,
lastAgentStatus: titleState,
lastAgentStatusStartedAtEpochMs: 1_700_000_000_500,
lastAgentStatusRichInvalidatedAtEpochMs: 1_700_000_000_400
} as RuntimePtyWorktreeRecord
const renewed = renewRuntimeMobileAgentStatusFromPtyTitle(
workingStatus({ state: sourceState, lastAssistantMessageIsToolOutput: toolOutput }),
pty
)
expect(renewed?.state).toBe(titleState === 'working' ? 'working' : 'done')
if (titleState === 'working' || sourceState === 'working') {
expect(renewed?.lastAssistantMessage).toBeUndefined()
expect(renewed?.lastAssistantMessageIsToolOutput).toBeUndefined()
} else {
expect(renewed).toMatchObject({
lastAssistantMessage: MESSAGE,
lastAssistantMessageIsToolOutput: toolOutput
})
}
}
)
it.each([
{ sourceState: 'done', toolOutput: false },
{ sourceState: 'done', toolOutput: true },
{ sourceState: 'working', toolOutput: false }
] as const)(
'keeps only a completed reply when a shell leaf title demotes the mobile projection $sourceState/$toolOutput',
({ sourceState, toolOutput }) => {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub sets the pty fields the title clock and handle publication read.
const pty = {
ptyId: 'live-pty',
connected: true,
incarnationId: 'live-incarnation',
lastOscTitleEpochMs: null,
lastAgentStatus: null,
lastAgentStatusStartedAtEpochMs: null,
lastAgentStatusRichInvalidatedAtEpochMs: null
} as RuntimePtyWorktreeRecord
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements every host member this terminal projection path calls.
const host = {
tabs: new Map(),
leaves: new Map([
[
'leaf',
{
ptyId: pty.ptyId,
connected: true,
paneTitle: 'bash',
paneTitleUpdatedAt: 1
}
]
]),
ptysById: new Map([[pty.ptyId, pty]]),
getLiveBrowserTabs: () => new Map(),
getProviderSessionRows: () => [],
getProviderSessionSnapshot: () => [],
getLeafKey: () => 'leaf',
findPty: () => pty,
getRetainedStatus: () => null,
getTrackedTitle: () => null,
getTitleDisplayClear: () => null,
issuePtyHandle: vi.fn(() => 'handle'),
recordPty: vi.fn(() => pty),
buildPtyStatus: () => ({}),
sanitizeGroups: () => [],
pruneGroupLayout: () => null,
collectTabIds: () => new Set()
} as unknown as RuntimeMobileSessionProjectionHost
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: one terminal tab is the only snapshot field this projection path reads.
const snapshot = {
worktree: 'workspace',
publicationEpoch: 'headless:epoch',
tabs: [
{
type: 'terminal',
id: 'tab::leaf',
parentTabId: 'tab',
leafId: 'leaf',
title: 'bash',
agentStatus: workingStatus({
state: sourceState,
interrupted: true,
lastAssistantMessageIsToolOutput: toolOutput
}),
isActive: true
}
]
} as RuntimeMobileSessionTabsSnapshot
const tab = projectRuntimeMobileSessionTabs(snapshot, host).tabs[0]
const agentStatus = tab?.type === 'terminal' ? tab.agentStatus : undefined
expect(agentStatus).toMatchObject({ state: 'done', agentType: 'claude', prompt: '' })
expect(agentStatus?.terminalTitle).toBeUndefined()
if (sourceState === 'done') {
expect(agentStatus).toMatchObject({
lastAssistantMessage: MESSAGE,
lastAssistantMessageIsToolOutput: toolOutput,
interrupted: true
})
} else {
expect(agentStatus?.lastAssistantMessage).toBeUndefined()
expect(agentStatus?.lastAssistantMessageIsToolOutput).toBeUndefined()
expect(agentStatus?.interrupted).toBeUndefined()
}
}
)
})
@@ -14,6 +14,7 @@ import { isTerminalLeafId, makePaneKey } from '../../shared/stable-pane-id'
import { resolveTerminalTabTitle } from '../../shared/tab-title-resolution'
import { indexAgentStatusRowsByPaneKey } from '../agent-hooks/agent-status-pane-index'
import {
demoteRuntimeMobileAgentStatus,
renewRuntimeMobileAgentStatusFromPtyTitle,
selectRuntimeHookAgentRowForPane
} from './runtime-mobile-agent-status-projection'
@@ -242,20 +243,7 @@ export function projectRuntimeMobileSessionTabs(
: // Why: idle live title → drop stale "working" (no spinner) but keep agent identity so native chat can still address the transcript.
normalizedTabAgentStatus?.agentType != null
? {
agentStatus: {
state: 'done' as const,
prompt: '',
updatedAt: statusPty?.lastOscTitleEpochMs ?? normalizedTabAgentStatus.updatedAt,
stateStartedAt:
statusPty?.lastAgentStatusStartedAtEpochMs ??
normalizedTabAgentStatus.stateStartedAt,
paneKey: normalizedTabAgentStatus.paneKey,
stateHistory: [],
agentType: normalizedTabAgentStatus.agentType,
...(normalizedTabAgentStatus.providerSession
? { providerSession: normalizedTabAgentStatus.providerSession }
: {})
}
agentStatus: demoteRuntimeMobileAgentStatus(normalizedTabAgentStatus, statusPty)
}
: null
// Why: web/mobile clients hold handles across renderer graph syncs; leaf handles are epoch-bound but PTY handles stay streamable.