mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 17:47:40 +00:00
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:
co-authored by
Neil
Neil
parent
909dce0a0b
commit
f4a3860073
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user