diff --git a/src/main/runtime/runtime-mobile-agent-status-projection.ts b/src/main/runtime/runtime-mobile-agent-status-projection.ts index 4ac6f32c7fe..ee0af6d268b 100644 --- a/src/main/runtime/runtime-mobile-agent-status-projection.ts +++ b/src/main/runtime/runtime-mobile-agent-status-projection.ts @@ -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') || diff --git a/src/main/runtime/runtime-mobile-last-assistant-message.test.ts b/src/main/runtime/runtime-mobile-last-assistant-message.test.ts new file mode 100644 index 00000000000..bf945e6f304 --- /dev/null +++ b/src/main/runtime/runtime-mobile-last-assistant-message.test.ts @@ -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 { + 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() + } + } + ) +}) diff --git a/src/main/runtime/runtime-mobile-session-projection.ts b/src/main/runtime/runtime-mobile-session-projection.ts index 2bce38db342..e5a0769c276 100644 --- a/src/main/runtime/runtime-mobile-session-projection.ts +++ b/src/main/runtime/runtime-mobile-session-projection.ts @@ -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.